From 9b8edc4e91c4d9d28812a3e130177a6c0c46aa62 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 6 Jul 2026 20:09:46 +0200 Subject: [PATCH 01/10] firs -fix applied --- app/[locale]/admin/sellers/create/page.tsx | 4 +- app/[locale]/admin/sellers/page.tsx | 4 +- app/[locale]/auth/change-password/page.tsx | 6 +- app/[locale]/auth/reset-password/page.tsx | 8 +- app/[locale]/auth/signup/page.tsx | 6 +- app/[locale]/auth/verify-email/page.tsx | 2 +- app/[locale]/cart/design-preview.tsx | 6 +- app/[locale]/layout.tsx | 8 +- app/[locale]/orders/[orderId]/page.tsx | 6 +- app/[locale]/orders/page.tsx | 2 +- .../[id]/customization-experience.tsx | 4 +- .../products/[id]/customization-form.tsx | 16 ++-- .../products/[id]/customization-preview.tsx | 10 +-- app/[locale]/products/[id]/page.tsx | 6 +- app/[locale]/profile/page.tsx | 34 ++++---- app/[locale]/seller/products/product-form.tsx | 6 +- app/api/auth/[...nextauth]/route.ts | 4 +- app/api/auth/forgot-password/route.ts | 4 +- app/api/auth/reset-password/route.ts | 4 +- app/api/uploads/guest/presigned-url/route.ts | 2 +- .../uploads/local/[...storageKey]/route.ts | 2 +- components/products/infinite-product-list.tsx | 4 +- .../search-input-with-suggestions.tsx | 8 +- composition-root/container.ts | 20 ++--- modules/auth/domain/rate-limiter.ts | 4 +- modules/auth/domain/session.ts | 4 +- modules/auth/domain/user-lookup.ts | 4 +- modules/cart/application/checkout-cart.ts | 2 +- .../cart/application/migrate-guest-cart.ts | 6 +- modules/cart/application/update-cart-item.ts | 6 +- modules/cart/domain/cart-repository.ts | 5 +- .../infrastructure/prisma-cart-repository.ts | 8 +- .../components/add-to-cart-button.tsx | 44 +++++----- .../presentation/components/cart-icon.tsx | 4 +- .../components/cart-merge-detector.tsx | 2 +- .../presentation/components/cart-popup.tsx | 82 ++++++++++--------- .../presentation/components/cart-view.tsx | 3 +- .../components/checkout-confirm-button.tsx | 2 +- .../presentation/components/merge-dialog.tsx | 2 +- .../cart/presentation/guest-cart-context.tsx | 72 ++++++++-------- .../create-customer-customization.ts | 9 +- .../application/update-customization.ts | 14 ++-- modules/orders/domain/order-repository.ts | 6 +- .../components/design-preview.tsx | 6 +- .../application/update-product-use-case.ts | 26 +++--- .../prisma-product-repository.ts | 4 +- .../domain/search-history-repository.ts | 4 +- .../application/cleanup-uploads-use-case.ts | 4 +- .../application/create-upload-use-case.ts | 2 +- .../application/delete-upload-use-case.ts | 4 +- modules/uploads/domain/upload-repository.ts | 4 +- .../domain/value-objects/upload-status.ts | 4 +- .../domain/value-objects/upload-type.ts | 4 +- .../infrastructure/r2-storage-adapter.ts | 2 +- modules/users/domain/user-repository.ts | 4 +- proxy.ts | 6 +- scripts/optimize-sprites.mjs | 2 +- shared/contracts/email/email-queue-port.ts | 5 +- shared/infrastructure/auth-options.ts | 2 +- shared/infrastructure/prisma.ts | 2 +- .../domain/value-objects/localized-date.ts | 2 +- shared/kernel/domain/value-objects/money.ts | 26 +++--- shared/kernel/escape-html.ts | 10 +-- shared/layout/header-nav.tsx | 2 +- shared/layout/login-modal.tsx | 10 +-- shared/lib/normalize-text.ts | 2 +- .../components/file-upload-dropzone.tsx | 2 +- shared/presentation/lib/to-absolute-url.ts | 2 +- shared/ui/eye-toggle-wrapper.tsx | 8 +- shared/ui/file-upload-dropzone.tsx | 2 +- shared/ui/modal.tsx | 8 +- shared/ui/price-field.tsx | 2 +- shared/ui/quantity-controls.tsx | 4 +- shared/ui/tag-list.tsx | 12 +-- shared/ui/wave-transition.tsx | 2 +- 75 files changed, 335 insertions(+), 299 deletions(-) diff --git a/app/[locale]/admin/sellers/create/page.tsx b/app/[locale]/admin/sellers/create/page.tsx index 09d5f283..0aff959c 100644 --- a/app/[locale]/admin/sellers/create/page.tsx +++ b/app/[locale]/admin/sellers/create/page.tsx @@ -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..73918942 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 }), }} /> 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..4a255a2e 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; } @@ -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..c91cdacd 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(() => { 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]/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..c1474949 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 (
diff --git a/app/[locale]/orders/page.tsx b/app/[locale]/orders/page.tsx index 7b47b385..263d357e 100644 --- a/app/[locale]/orders/page.tsx +++ b/app/[locale]/orders/page.tsx @@ -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]/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 && ( 0 ? 'customization-size-error' : undefined; return (
{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 +133,7 @@ export function CustomizationForm({ - {errors.size && ( + {errors.size != null && ( 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]/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..ff03cd41 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; - } - + }); + } catch (error: unknown) { + if (error instanceof NotFoundError) { + result = fallbackResult; + } else { throw error; - })) as PaginatedResult; + } + } 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 9c82f456..b2094217 100644 --- a/app/[locale]/seller/products/product-form.tsx +++ b/app/[locale]/seller/products/product-form.tsx @@ -258,7 +258,7 @@ export function ProductForm({ ) => { setForm((current) => ({ ...current, [field]: value })); setErrors((current) => { - if (!current[field]) return current; + if (!(field in current)) return current; const next = { ...current }; delete next[field]; return next; @@ -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 && !(path in next)) { next[path] = issue.message; } } @@ -382,7 +382,13 @@ export function ProductForm({ }); if (!response.ok) { - const data = (await response.json().catch(() => null)) as { + const data = (() => { + try { + return await response.json(); + } catch { + return null; + } + })() as { error?: string; } | null; throw new Error(data?.error || labels.error); 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/components/products/infinite-product-list.tsx b/components/products/infinite-product-list.tsx index 247c8335..fb2dfd8e 100644 --- a/components/products/infinite-product-list.tsx +++ b/components/products/infinite-product-list.tsx @@ -111,7 +111,7 @@ 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(''); } @@ -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 e2ec0fb6..186357f5 100644 --- a/components/products/search-input-with-suggestions.tsx +++ b/components/products/search-input-with-suggestions.tsx @@ -246,34 +246,34 @@ export function SearchInputWithSuggestions({ aria-label={labels.recentSearchesLabel} className={styles.suggestionsList} > - {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} -
  • - ) : 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 f018295c..bf8b0b5b 100644 --- a/composition-root/container.ts +++ b/composition-root/container.ts @@ -152,171 +152,48 @@ let _isSearchHistoryEventsSubscribed = 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((error) => { - console.error('[container] Role seed failed:', error); - }); - } - - // --- 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 { + // Calling each getter triggers lazy initialization of its dependency. + 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 (!_isCartEventsSubscribed) { @@ -346,290 +223,365 @@ export function initContainer(): void { /** * Returns the EmailSender bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getEmailSender(): EmailSender { - if (!_emailSender) initContainer(); - return _emailSender!; + if (!_emailSender) { + _emailSender = + process.env.NODE_ENV === 'production' + ? new BrevoEmailSender() + : new ConsoleEmailSender(); + } + return _emailSender; } /** * Returns the OutboxRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getOutboxRepository(): OutboxRepository { - if (!_outboxRepository) initContainer(); - return _outboxRepository!; + if (!_outboxRepository) { + _outboxRepository = new PrismaOutboxRepository(); + } + return _outboxRepository; } /** * Returns the PasswordHasher bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getPasswordHasher(): PasswordHasher { - if (!_passwordHasher) initContainer(); - return _passwordHasher!; + if (!_passwordHasher) { + _passwordHasher = { + hash: hashPassword, + verify: verifyPassword, + }; + } + return _passwordHasher; } /** * Returns the RateLimiter bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getRateLimiter(): RateLimiter { - if (!_rateLimiter) initContainer(); - return _rateLimiter!; + if (!_rateLimiter) { + _rateLimiter = new PrismaRateLimiter(); + } + return _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. + * Lazily initializes on first access. + * Needs SecretsPort, which is also lazy-initialized on first access. */ export function getResetTokenCodec(): ResetTokenCodec { if (!_resetTokenCodec) { - initContainer(); - _resetTokenCodec = new JwtResetTokenCodec(_secrets!.getAuthSecret()); + _resetTokenCodec = new JwtResetTokenCodec(getSecrets().getAuthSecret()); } return _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. + * Default binding is the in-memory eventBus singleton. */ export function getEventBus(): EventBusPort { - if (!_eventBus) initContainer(); - return _eventBus!; + if (!_eventBus) { + _eventBus = eventBus; + } + return _eventBus; } /** * Returns the SecretsPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getSecrets(): SecretsPort { - if (!_secrets) initContainer(); - return _secrets!; + if (!_secrets) { + _secrets = new ProcessEnvSecrets(); + } + return _secrets; } /** * Returns the SessionPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getSession(): SessionPort { - if (!_session) initContainer(); - return _session!; + if (!_session) { + _session = new NextAuthSessionAdapter(); + } + return _session; } /** * Returns the UserRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getUserRepository(): UserRepository { - if (!_userRepository) initContainer(); - return _userRepository!; + if (!_userRepository) { + _userRepository = new PrismaUserRepository(); + } + return _userRepository; } /** * Returns the RoleRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access and seeds default roles. */ export function getRoleRepository(): RoleRepository { - if (!_roleRepository) initContainer(); - return _roleRepository!; + if (!_roleRepository) { + _roleRepository = new PrismaRoleRepository(); + // Seed default roles on first boot (idempotent, no-op if roles exist). + const seedRoles = new SeedRolesUseCase(_roleRepository); + (async () => { + try { + await seedRoles.execute(); + } catch (error) { + console.error('[container] Role seed failed:', error); + } + })(); + } + return _roleRepository; } /** * Returns the OrderRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getOrderRepository(): OrderRepository { - if (!_orderRepository) initContainer(); - return _orderRepository!; + if (!_orderRepository) { + _orderRepository = new PrismaOrderRepository(); + } + return _orderRepository; } /** * Returns the CheckoutGroupLookupPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getCheckoutGroupLookup(): CheckoutGroupLookupPort { - if (!_checkoutGroupLookup) initContainer(); - return _checkoutGroupLookup!; + if (!_checkoutGroupLookup) { + _checkoutGroupLookup = new PrismaCheckoutGroupLookup(); + } + return _checkoutGroupLookup; } /** * Returns the CheckoutGroupPaymentPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getCheckoutGroupPaymentPort(): CheckoutGroupPaymentPort { - if (!_checkoutGroupPaymentPort) initContainer(); - return _checkoutGroupPaymentPort!; + if (!_checkoutGroupPaymentPort) { + _checkoutGroupPaymentPort = new PrismaCheckoutGroupPaymentPort(); + } + return _checkoutGroupPaymentPort; } /** * Returns the ProductRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getProductRepository(): ProductRepository { - if (!_productRepository) initContainer(); - return _productRepository!; + if (!_productRepository) { + _productRepository = new PrismaProductRepository(); + } + return _productRepository; } /** * Returns the EmailQueueRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getEmailQueueRepository(): EmailQueueRepository { - if (!_emailQueueRepository) initContainer(); - return _emailQueueRepository!; + if (!_emailQueueRepository) { + _emailQueueRepository = new PrismaEmailQueueRepository(); + } + return _emailQueueRepository; } /** * Returns the UserLookupPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getUserLookup(): UserLookupPort { - if (!_userLookup) initContainer(); - return _userLookup!; + if (!_userLookup) { + _userLookup = new PrismaUserLookup(); + } + return _userLookup; } /** * Returns the ForgotPasswordEmailPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getForgotPasswordEmailPort(): ForgotPasswordEmailPort { - if (!_forgotPasswordEmailPort) initContainer(); - return _forgotPasswordEmailPort!; + if (!_forgotPasswordEmailPort) { + _forgotPasswordEmailPort = new ConsoleForgotPasswordEmail(); + } + return _forgotPasswordEmailPort; } /** * Returns the UsedResetTokenStore bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getUsedResetTokenStore(): UsedResetTokenStorePort { - if (!_usedResetTokenStore) initContainer(); - return _usedResetTokenStore!; + if (!_usedResetTokenStore) { + _usedResetTokenStore = new MemoryUsedResetTokenStore(); + } + return _usedResetTokenStore; } /** * Returns the SellerRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getSellerRepository(): SellerRepository { - if (!_sellerRepository) initContainer(); - return _sellerRepository!; + if (!_sellerRepository) { + _sellerRepository = new PrismaSellerRepository(); + } + return _sellerRepository; } /** * Returns the SellerLookupPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access, resolving SellerRepository via its own getter. */ export function getSellerLookup(): SellerLookupPort { - if (!_sellerLookup) initContainer(); - return _sellerLookup!; + if (!_sellerLookup) { + _sellerLookup = new SellerLookupAdapter(getSellerRepository()); + } + return _sellerLookup; } /** * 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). + * Lazily initializes on first access. */ export function getTransactionRunner(): TransactionRunner { - if (!_transactionRunner) initContainer(); - return _transactionRunner!; + if (!_transactionRunner) { + _transactionRunner = new PrismaTransactionRunner(); + } + return _transactionRunner; } /** * Returns the UserVerificationPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access, resolving UserRepository via its own getter. */ export function getUserVerification(): UserVerificationPort { - if (!_userVerification) initContainer(); - return _userVerification!; + if (!_userVerification) { + _userVerification = new UserVerificationAdapter(getUserRepository()); + } + return _userVerification; } /** * Returns the RoleValidatorPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access, resolving RoleRepository via its own getter. */ export function getRoleValidator(): RoleValidatorPort { - if (!_roleValidator) initContainer(); - return _roleValidator!; + if (!_roleValidator) { + _roleValidator = new RoleValidatorAdapter(getRoleRepository()); + } + return _roleValidator; } /** * Returns the StoragePort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access — R2 in production, local storage for seed/data tasks. */ export function getStoragePort(): StoragePort { - if (!_storagePort) initContainer(); - return _storagePort!; + if (!_storagePort) { + _storagePort = isLocalUploadStorage() + ? new LocalStorageAdapter() + : new R2StorageAdapter(); + } + return _storagePort; } /** * Returns the UploadRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getUploadRepository(): UploadRepository { - if (!_uploadRepository) initContainer(); - return _uploadRepository!; + if (!_uploadRepository) { + _uploadRepository = new PrismaUploadRepository(); + } + return _uploadRepository; } /** * Returns the CartRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getCartRepository(): CartRepository { - if (!_cartRepository) initContainer(); - return _cartRepository!; + if (!_cartRepository) { + _cartRepository = new PrismaCartRepository(); + } + return _cartRepository; } /** * Returns the CartProductRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access, resolving ProductRepository via its own getter. */ export function getCartProductRepository(): CartProductRepository { - if (!_cartProductRepository) initContainer(); - return _cartProductRepository!; + if (!_cartProductRepository) { + _cartProductRepository = new CartProductRepositoryAdapter( + getProductRepository(), + ); + } + return _cartProductRepository; } /** * Returns the PaidOrderCountPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access, resolving OrderRepository via its own getter. */ export function getPaidOrderCountPort(): PaidOrderCountPort { - if (!_paidOrderCountPort) initContainer(); - return _paidOrderCountPort!; + if (!_paidOrderCountPort) { + _paidOrderCountPort = new PrismaPaidOrderCountAdapter(getOrderRepository()); + } + return _paidOrderCountPort; } /** - * Returns the CustomizationLookupPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Returns the CustomizationRepository bound for the current environment. + * Lazily initializes on first access. */ -export function getCustomizationLookup(): CartCustomizationLookupPort { - if (!_customizationLookup) initContainer(); - return _customizationLookup!; +export function getCustomizationRepository(): CustomizationRepository { + if (!_customizationRepository) { + _customizationRepository = new PrismaCustomizationRepository(); + } + return _customizationRepository; } /** - * Returns the CustomizationRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Returns the CustomizationLookupPort bound for the current environment. + * Lazily initializes on first access, resolving CustomizationRepository via its own getter. */ -export function getCustomizationRepository(): CustomizationRepository { - if (!_customizationRepository) initContainer(); - return _customizationRepository!; +export function getCustomizationLookup(): CartCustomizationLookupPort { + if (!_customizationLookup) { + _customizationLookup = new CustomizationLookupAdapter( + getCustomizationRepository(), + ); + } + return _customizationLookup; } /** * Returns the SearchHistoryRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. + * Lazily initializes on first access. */ export function getSearchHistoryRepository(): SearchHistoryRepository { - if (!_searchHistoryRepository) initContainer(); - return _searchHistoryRepository!; + if (!_searchHistoryRepository) { + _searchHistoryRepository = new PrismaSearchHistoryRepository(); + } + return _searchHistoryRepository; } // --------------------------------------------------------------------------- diff --git a/eslint.config.mjs b/eslint.config.mjs index fd5f2cbe..daa834a5 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']), diff --git a/modules/auth/domain/rate-limiter.ts b/modules/auth/domain/rate-limiter.ts index 7c9d032a..9f3a0ed2 100644 --- a/modules/auth/domain/rate-limiter.ts +++ b/modules/auth/domain/rate-limiter.ts @@ -34,7 +34,7 @@ export interface RateLimiter { recordLoginAttempt( email: string, ip: string, - success: boolean, + isSuccess: boolean, ): Promise; } diff --git a/modules/auth/domain/value-objects/verification-token.ts b/modules/auth/domain/value-objects/verification-token.ts index 254856c3..0d1324f2 100644 --- a/modules/auth/domain/value-objects/verification-token.ts +++ b/modules/auth/domain/value-objects/verification-token.ts @@ -1,10 +1,6 @@ export class VerificationToken { readonly value: string; - private constructor(value: string) { - this.value = value; - } - static create(value: string): VerificationToken { const trimmed = value.trim(); @@ -15,6 +11,10 @@ export class VerificationToken { return new VerificationToken(trimmed); } + 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 0bcc0dc3..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 968bd45f..9bd829ff 100644 --- a/modules/cart/application/migrate-guest-cart.ts +++ b/modules/cart/application/migrate-guest-cart.ts @@ -67,6 +67,41 @@ export class MigrateGuestCart { private customizationCreator?: CustomizationCreatePort, ) {} + 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), + ), + }; + } + + private emptyCart(userId: string): CartEntity { + const now = new Date(); + return { + id: '', + userId, + status: CartStatus.Active, + items: [], + createdAt: now, + updatedAt: now, + }; + } + async execute(dto: MigrateGuestCartDTO): Promise { const serverCart = await this.cartRepository.findActiveByUserId(dto.userId); @@ -257,39 +292,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 --- @@ -396,8 +398,12 @@ 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)), + ) ); } 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..0fc6e37e 100644 --- a/modules/cart/domain/value-objects/quantity.ts +++ b/modules/cart/domain/value-objects/quantity.ts @@ -15,10 +15,6 @@ const MAX_QUANTITY = 99; 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 +29,10 @@ export class Quantity { return new Quantity(amount); } + 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 63de48e1..8339b457 100644 --- a/modules/cart/infrastructure/prisma-cart-repository.ts +++ b/modules/cart/infrastructure/prisma-cart-repository.ts @@ -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 094e22dc..0bc92e05 100644 --- a/modules/cart/presentation/components/add-to-cart-button.tsx +++ b/modules/cart/presentation/components/add-to-cart-button.tsx @@ -53,7 +53,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 +71,7 @@ function guestCustomizationMatches( ); } -function authCustomizationMatches( +function isAuthCustomizationMatching( customizations: Array<{ text?: string | null; color?: string | null; @@ -182,7 +182,7 @@ export function AddToCartButton({ }>; }) => item.productId === productId && - authCustomizationMatches( + isAuthCustomizationMatching( item.customizations ?? [], normalizedCustomization, ), @@ -208,7 +208,7 @@ export function AddToCartButton({ : items.find( (i) => i.productId === productId && - guestCustomizationMatches(i, normalizedCustomization), + isGuestCustomizationMatching(i, normalizedCustomization), ); const guestDiffMatch = !isAuthenticated && !guestMatch @@ -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 > 0 || - 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 && + (draft.text || + draft.color || + draft.size > 0 || + draft.imageUrl || + 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, ], ); @@ -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') { diff --git a/modules/cart/presentation/components/cart-icon.tsx b/modules/cart/presentation/components/cart-icon.tsx index b1b4e26f..ca8437dc 100644 --- a/modules/cart/presentation/components/cart-icon.tsx +++ b/modules/cart/presentation/components/cart-icon.tsx @@ -68,7 +68,7 @@ export function CartIcon({ alt }: CartIconProps) { type="button" onClick={open} className={styles.cartIconWrapper} - aria-label={`${alt}${count > 0 ? ` (${count})` : ''}`} + aria-label={count > 0 ? `${alt} (${count})` : alt} > {item.productName} + ); + } + return null; + } + + let content: ReactNode; + if (loading) { + content =

    {labels.loading}

    ; + } else if (items.length === 0) { + content = ( +
    +

    {labels.empty}

    + +
    + ); + } 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 0f1c9fa8..9ce165df 100644 --- a/modules/cart/presentation/components/cart-view.tsx +++ b/modules/cart/presentation/components/cart-view.tsx @@ -109,7 +109,9 @@ function buildCustomizationHref( ); const query = params.toString(); - return `/${locale}/products/${productId}${query ? `?${query}` : ''}`; + return query + ? `/${locale}/products/${productId}?${query}` + : `/${locale}/products/${productId}`; } /** @@ -289,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 c7b0e27e..c35ab869 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({ shouldAcceptPriceChanges }), }); if (confirmRes.ok) { diff --git a/modules/customizations/application/create-customer-customization.ts b/modules/customizations/application/create-customer-customization.ts index 594dc618..f51450ae 100644 --- a/modules/customizations/application/create-customer-customization.ts +++ b/modules/customizations/application/create-customer-customization.ts @@ -91,35 +91,30 @@ export class CreateCustomerCustomization { ); } - 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; - } + const mode = config.mode; + const textRequired = mode === 'description' || mode === 'text'; + + if (textRequired && !hasText) { + throw new ValidationError( + 'Text customization is required for this product', + 'Customization is not allowed for this product', + ); + } + + const photoRequired = mode === 'photo' || mode === 'text_photo'; + + if (photoRequired && !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', + ); } } } diff --git a/modules/customizations/domain/value-objects/customization-options.ts b/modules/customizations/domain/value-objects/customization-options.ts index 4c89e4eb..baad0615 100644 --- a/modules/customizations/domain/value-objects/customization-options.ts +++ b/modules/customizations/domain/value-objects/customization-options.ts @@ -46,24 +46,13 @@ export class CustomizationOptions { 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) { if (color.trim().length === 0) { throw new Error('Customization color cannot be empty if provided'); @@ -72,7 +61,9 @@ export class CustomizationOptions { throw new Error('Customization color must be at most 50 characters'); } } + } + private static validateSize(size: string | undefined): void { if (size !== undefined) { if (size.trim().length === 0) { throw new Error('Customization size cannot be empty if provided'); @@ -81,16 +72,20 @@ export class CustomizationOptions { throw new Error('Customization size must be at most 50 characters'); } } + } + private static validateImageUrl(imageUrl: string | undefined): void { if (imageUrl !== undefined) { const urlPattern = /^https?:\/\/.+/; if (!urlPattern.test(imageUrl)) { throw new Error('Customization image URL must be a valid URL'); } } + } - const designPosition = data.designPosition ?? undefined; - + private static validateDesignPosition( + designPosition: CustomizationDesignPosition | null | undefined, + ): void { if ( designPosition !== undefined && designPosition !== null && @@ -102,6 +97,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, @@ -119,12 +134,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..661d1152 100644 --- a/modules/customizations/infrastructure/prisma-customization-repository.ts +++ b/modules/customizations/infrastructure/prisma-customization-repository.ts @@ -10,8 +10,8 @@ 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 { @@ -74,18 +74,6 @@ export class PrismaCustomizationRepository implements CustomizationRepository { await prisma.customization.delete({ where: { id } }); } - async isReferencedByOrders(id: string): Promise { - // Check if any OrderLineItem has this id in its customizationIdList array - const count = await prisma.orderLineItem.count({ - where: { - customizationIdList: { - has: id, - }, - }, - }); - return count > 0; - } - private toDomain(row: { id: string; productId: string; @@ -107,6 +95,18 @@ export class PrismaCustomizationRepository implements CustomizationRepository { createdAt: row.createdAt, }; } + + async isReferencedByOrders(id: string): Promise { + // Check if any OrderLineItem has this id in its customizationIdList array + const count = await prisma.orderLineItem.count({ + where: { + customizationIdList: { + has: id, + }, + }, + }); + return count > 0; + } } 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..509bf9a1 100644 --- a/modules/email/infrastructure/prisma-email-queue-repository.ts +++ b/modules/email/infrastructure/prisma-email-queue-repository.ts @@ -10,11 +10,11 @@ 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 { @@ -72,7 +72,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 @@ -123,23 +123,6 @@ export class PrismaEmailQueueRepository implements EmailQueueRepository { }); } - async reschedule( - id: string, - retryCount: number, - scheduledAt: Date, - error: string, - ): Promise { - await prisma.emailQueue.update({ - where: { id }, - data: { - status: 'PENDING', - retryCount, - scheduledAt, - error, - }, - }); - } - private toWorkerEntry(row: { id: string; to: string; @@ -167,4 +150,21 @@ export class PrismaEmailQueueRepository implements EmailQueueRepository { scheduledAt: row.scheduledAt, }; } + + async reschedule( + id: string, + retryCount: number, + scheduledAt: Date, + error: string, + ): Promise { + await prisma.emailQueue.update({ + where: { id }, + data: { + status: 'PENDING', + retryCount, + scheduledAt, + error, + }, + }); + } } diff --git a/modules/orders/application/assign-to-production-use-case.ts b/modules/orders/application/assign-to-production-use-case.ts index 2ab1289b..8204353a 100644 --- a/modules/orders/application/assign-to-production-use-case.ts +++ b/modules/orders/application/assign-to-production-use-case.ts @@ -28,10 +28,10 @@ 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 { /** @@ -47,6 +47,43 @@ export class AssignToProductionUseCase { private transactionalService?: TransactionalOrderPort, ) {} + /** + * 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, + ); + } + }, + ); + } + /** * Executes the assign-to-production operation for an order. * @@ -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..3d8486f3 100644 --- a/modules/orders/application/handle-cart-checked-out.ts +++ b/modules/orders/application/handle-cart-checked-out.ts @@ -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..517b1cc4 100644 --- a/modules/orders/application/mark-as-paid-use-case.ts +++ b/modules/orders/application/mark-as-paid-use-case.ts @@ -29,10 +29,10 @@ 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 { /** @@ -48,6 +48,34 @@ export class MarkAsPaidUseCase { private transactionalService?: TransactionalOrderPort, ) {} + /** + * 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); + } + }); + } + /** * Executes the mark-as-paid operation for an order. * @@ -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/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..bbb1e8cd 100644 --- a/modules/products/domain/value-objects/product-customization-config.ts +++ b/modules/products/domain/value-objects/product-customization-config.ts @@ -110,19 +110,6 @@ export class ProductCustomizationConfig { 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 +146,19 @@ export class ProductCustomizationConfig { }); } + 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..54d038d8 100644 --- a/modules/products/domain/value-objects/product-description.ts +++ b/modules/products/domain/value-objects/product-description.ts @@ -11,10 +11,6 @@ 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 +25,10 @@ export class ProductDescription { return new ProductDescription(trimmed); } + 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..3b241481 100644 --- a/modules/products/domain/value-objects/product-name.ts +++ b/modules/products/domain/value-objects/product-name.ts @@ -11,10 +11,6 @@ export class ProductName { readonly value: string; - private constructor(value: string) { - this.value = value; - } - static create(value: string): ProductName { const trimmed = value.trim(); @@ -29,6 +25,10 @@ export class ProductName { return new ProductName(trimmed); } + 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..c54184f8 100644 --- a/modules/products/domain/value-objects/product-price.ts +++ b/modules/products/domain/value-objects/product-price.ts @@ -4,6 +4,16 @@ import type { Currency } from '@/shared/kernel/domain/value-objects/currency'; export class ProductPrice { readonly money: Money; + 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); + } + private constructor(money: Money) { this.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 79efc875..9e25b7bd 100644 --- a/modules/products/infrastructure/prisma-product-repository.ts +++ b/modules/products/infrastructure/prisma-product-repository.ts @@ -70,73 +70,16 @@ export class PrismaProductRepository implements ProductRepository { return products.map((product) => toDomainProduct(product)); } - async findPaginated( - filter: ProductsListFilter, - ): Promise> { - const locale = filter.lang ?? 'es'; - const sortDir = filter.sortDir ?? 'desc'; - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; - - // Build WHERE conditions WITHOUT the q filter — the search term - // is matched with PostgreSQL unaccent() for accent-insensitive - // comparison (handles "café" ↔ "cafe" both directions). - const where = this.buildWhere(filter, locale, true); - - if (filter.q !== undefined && filter.q !== '') { - const ids = await this.searchByUnaccent(filter.q, locale); - if (ids.length > 0) { - if (Object.keys(where).length === 0) { - where.id = { in: ids }; - } else { - if (!Array.isArray(where.AND)) where.AND = [where.AND ?? {}]; - where.AND.push({ id: { in: ids } }); - } - } else { - // No matches — force empty result so the Prisma query - // returns zero items without a full table scan. - return { items: [], total: 0, page, pageSize, totalPages: 0 }; - } - } - - const products = await prisma.product.findMany({ - where, - include: { - seller: true, - category: true, - translations: { - where: { locale: { in: [locale, 'es'] } }, - }, - images: { - orderBy: { position: 'asc' }, - }, - tags: true, - }, - orderBy: { createdAt: sortDir }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - const total = await prisma.product.count({ where }); - - return { - items: products.map((product) => toDomainProduct(product)), - total, - page, - pageSize, - totalPages: Math.ceil(total / pageSize), - }; - } - /** * Build Prisma WHERE conditions, optionally skipping the q filter. - * When skipQ is true, the search term is handled separately via + * 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, - skipQ: boolean = false, + shouldSkipQ: boolean = false, ): import('@prisma/client').Prisma.ProductWhereInput { const conditions: import('@prisma/client').Prisma.ProductWhereInput[] = []; @@ -156,9 +99,9 @@ export class PrismaProductRepository implements ProductRepository { conditions.push({ tags: { some: { slug: { in: filter.tags } } } }); } - if (!skipQ && filter.q !== undefined && filter.q !== '') { + if (!shouldSkipQ && filter.q !== undefined && filter.q !== '') { // Case-insensitive fallback when unaccent is not needed or - // when skipQ is false (e.g. seller queries without search). + // when shouldSkipQ is false (e.g. seller queries without search). conditions.push({ OR: [ { @@ -227,6 +170,63 @@ export class PrismaProductRepository implements ProductRepository { return rows.map((r) => r.id); } + async findPaginated( + filter: ProductsListFilter, + ): Promise> { + const locale = filter.lang ?? 'es'; + const sortDir = filter.sortDir ?? 'desc'; + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + + // Build WHERE conditions WITHOUT the q filter — the search term + // is matched with PostgreSQL unaccent() for accent-insensitive + // comparison (handles "café" ↔ "cafe" both directions). + const where = this.buildWhere(filter, locale, true); + + if (filter.q !== undefined && filter.q !== '') { + const ids = await this.searchByUnaccent(filter.q, locale); + if (ids.length > 0) { + if (Object.keys(where).length === 0) { + where.id = { in: ids }; + } else { + if (!Array.isArray(where.AND)) where.AND = [where.AND ?? {}]; + where.AND.push({ id: { in: ids } }); + } + } else { + // No matches — force empty result so the Prisma query + // returns zero items without a full table scan. + return { items: [], total: 0, page, pageSize, totalPages: 0 }; + } + } + + const products = await prisma.product.findMany({ + where, + include: { + seller: true, + category: true, + translations: { + where: { locale: { in: [locale, 'es'] } }, + }, + images: { + orderBy: { position: 'asc' }, + }, + tags: true, + }, + orderBy: { createdAt: sortDir }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + const total = await prisma.product.count({ where }); + + return { + items: products.map((product) => toDomainProduct(product)), + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }; + } + 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..d640be63 100644 --- a/modules/search-history/application/handle-product-search-executed.ts +++ b/modules/search-history/application/handle-product-search-executed.ts @@ -23,6 +23,23 @@ import { RecordSearchUseCase } from './record-search-use-case'; export class HandleProductSearchExecuted { constructor(private readonly recordSearch: RecordSearchUseCase) {} + /** + * 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); + } + }); + } + async handle( payload: ProductSearchExecutedPayload | null | undefined, ): Promise { @@ -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/sellers/infrastructure/prisma-seller-repository.ts b/modules/sellers/infrastructure/prisma-seller-repository.ts index f70aee1f..5f7284a0 100644 --- a/modules/sellers/infrastructure/prisma-seller-repository.ts +++ b/modules/sellers/infrastructure/prisma-seller-repository.ts @@ -64,14 +64,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( diff --git a/modules/sellers/presentation/components/seller-detail-form.tsx b/modules/sellers/presentation/components/seller-detail-form.tsx index 67205f67..c596fc58 100644 --- a/modules/sellers/presentation/components/seller-detail-form.tsx +++ b/modules/sellers/presentation/components/seller-detail-form.tsx @@ -50,7 +50,13 @@ export function SellerDetailForm({ }), }); - const responseText = await response.text().catch(() => ''); + const responseText = (() => { + try { + return await response.text(); + } catch { + return ''; + } + })(); if (!response.ok) { let errorMessage = ''; diff --git a/modules/uploads/infrastructure/r2-storage-adapter.ts b/modules/uploads/infrastructure/r2-storage-adapter.ts index e53aea48..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; 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 510f654a..a85fd2b8 100644 --- a/proxy.ts +++ b/proxy.ts @@ -20,7 +20,7 @@ function isKnownLocale(segment: string): boolean { return locales.includes(segment); } -function matchesProtectedPath( +function doesMatchProtectedPath( pathname: string, protectedPath: string, ): boolean { @@ -51,7 +51,7 @@ export async function proxy(request: NextRequest) { // ---- Auth gate for protected routes ---- const isProtected = protectedPaths.some((path) => - matchesProtectedPath(pathname, path), + doesMatchProtectedPath(pathname, path), ); if (isProtected) { diff --git a/scripts/optimize-sprites.mjs b/scripts/optimize-sprites.mjs index 4c9e7c3b..8915cdd2 100644 --- a/scripts/optimize-sprites.mjs +++ b/scripts/optimize-sprites.mjs @@ -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/i18n/get-dictionary.ts b/shared/i18n/get-dictionary.ts index 8d4c44bc..1b4fae89 100644 --- a/shared/i18n/get-dictionary.ts +++ b/shared/i18n/get-dictionary.ts @@ -12,5 +12,5 @@ const dictionaries: Record Promise> = { export const getDictionary = async ( locale: 'es' | 'cat', ): Promise => { - return dictionaries[locale] ? dictionaries[locale]() : dictionaries.es(); + return locale in dictionaries ? dictionaries[locale]() : dictionaries.es(); }; diff --git a/shared/kernel/container.ts b/shared/kernel/container.ts index 6101adbe..42a3ed7d 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,6 +14,8 @@ */ import type { EmailSender } from './email-sender'; +import { BrevoEmailSender } from './brevo-email-sender'; +import { ConsoleEmailSender } from './console-email-sender'; // --------------------------------------------------------------------------- // State @@ -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,13 +42,14 @@ 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.', - ); + _emailSender = + process.env.NODE_ENV === 'production' + ? new BrevoEmailSender() + : new ConsoleEmailSender(); } return _emailSender; } @@ -61,7 +57,7 @@ export function getEmailSender(): 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()); 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..9487f78d 100644 --- a/shared/kernel/domain/value-objects/address.ts +++ b/shared/kernel/domain/value-objects/address.ts @@ -4,18 +4,6 @@ export class Address { 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 +32,18 @@ export class Address { ); } + 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..5d55910c 100644 --- a/shared/kernel/domain/value-objects/email.ts +++ b/shared/kernel/domain/value-objects/email.ts @@ -1,10 +1,6 @@ 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 +20,10 @@ export class Email { return new Email(trimmed); } + 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 c0bd87cf..23b8e388 100644 --- a/shared/kernel/domain/value-objects/localized-date.ts +++ b/shared/kernel/domain/value-objects/localized-date.ts @@ -12,11 +12,6 @@ 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'); @@ -30,6 +25,11 @@ export class LocalizedDate { return new LocalizedDate(d, locale); } + 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 9a3682f5..ddd83455 100644 --- a/shared/kernel/domain/value-objects/money.ts +++ b/shared/kernel/domain/value-objects/money.ts @@ -4,16 +4,6 @@ export class Money { readonly amount: number; readonly currency: Currency; - /** - * Direct construction bypasses create() validation — used only internally by - * arithmetic methods that may produce valid intermediate states - * (e.g., negative from subtraction). - */ - private constructor(amount: number, currency: Currency) { - this.amount = amount; - this.currency = currency; - } - static create(amount: number, currency: Currency): Money { if (!Number.isFinite(amount)) { throw new TypeError('Money amount must be a finite number'); @@ -30,6 +20,16 @@ export class Money { return new Money(amount, currency); } + /** + * Direct construction bypasses create() validation — used only internally by + * arithmetic methods that may produce valid intermediate states + * (e.g., negative from subtraction). + */ + private constructor(amount: number, currency: Currency) { + this.amount = amount; + this.currency = currency; + } + add(other: Money): Money { this.assertSameCurrency(other); const resultAmount = this.amount + other.amount; 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..6916a0e1 100644 --- a/shared/kernel/domain/value-objects/password-hash.ts +++ b/shared/kernel/domain/value-objects/password-hash.ts @@ -1,10 +1,6 @@ export class PasswordHash { readonly value: string; - private constructor(value: string) { - this.value = value; - } - static create(hash: string): PasswordHash { const trimmed = hash.trim(); @@ -19,6 +15,10 @@ export class PasswordHash { return new PasswordHash(trimmed); } + 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/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/lib/normalize-text.ts b/shared/lib/normalize-text.ts index 743cf2c4..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').replaceAll(/[\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 f14f1bc7..f6629c46 100644 --- a/shared/presentation/components/file-upload-dropzone.tsx +++ b/shared/presentation/components/file-upload-dropzone.tsx @@ -114,9 +114,7 @@ export function FileUploadDropzone({ ) : null} {items.length === 0 ? ( - emptyLabel ? ( -

    {emptyLabel}

    - ) : null + emptyLabel &&

    {emptyLabel}

    ) : (
      {items.map((item) => { diff --git a/shared/presentation/status-labels.ts b/shared/presentation/status-labels.ts index 1859b705..b11608cd 100644 --- a/shared/presentation/status-labels.ts +++ b/shared/presentation/status-labels.ts @@ -19,6 +19,12 @@ export function resolveStatusLabel( fallback = status, ): string { const key = - map[status] ?? map[status.toLowerCase()] ?? map[status.toUpperCase()]; + status in map + ? map[status] + : status.toLowerCase() in map + ? map[status.toLowerCase()] + : status.toUpperCase() in map + ? map[status.toUpperCase()] + : undefined; return key && dict[key] ? dict[key] : 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/file-upload-dropzone.tsx b/shared/ui/file-upload-dropzone.tsx index f14f1bc7..f6629c46 100644 --- a/shared/ui/file-upload-dropzone.tsx +++ b/shared/ui/file-upload-dropzone.tsx @@ -114,9 +114,7 @@ export function FileUploadDropzone({ ) : null} {items.length === 0 ? ( - emptyLabel ? ( -

      {emptyLabel}

      - ) : null + emptyLabel &&

      {emptyLabel}

      ) : (
        {items.map((item) => { 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/quantity-controls.tsx b/shared/ui/quantity-controls.tsx index 8b828ded..0de89240 100644 --- a/shared/ui/quantity-controls.tsx +++ b/shared/ui/quantity-controls.tsx @@ -29,7 +29,11 @@ export function QuantityControls({ return (