From 25448e235e55beb8aec69c7b96da29d8f456e727 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 6 Jul 2026 18:58:13 +0200 Subject: [PATCH 1/4] better lint rules phase 1 --- eslint.config.mjs | 2 +- modules/products/infrastructure/prisma-product-repository.ts | 2 +- proxy.ts | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 384fdc7f..6bf43ea1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -187,7 +187,7 @@ export default [ 'unicorn/import-style': 'off', 'sonarjs/no-unused-vars': 'off', 'unicorn/no-declarations-before-early-exit': 'off', - 'unicorn/prefer-string-raw': 'off', + //'unicorn/prefer-string-raw': 'off', 'unicorn/no-for-each': 'off', 'unicorn/prefer-location-assign': 'off', diff --git a/modules/products/infrastructure/prisma-product-repository.ts b/modules/products/infrastructure/prisma-product-repository.ts index e84b857c..0f8ed8b2 100644 --- a/modules/products/infrastructure/prisma-product-repository.ts +++ b/modules/products/infrastructure/prisma-product-repository.ts @@ -204,7 +204,7 @@ export class PrismaProductRepository implements ProductRepository { let normQ = normalizeText(q); // Escape SQL ILIKE wildcards so user input like "100%" or "a_b" is // treated literally instead of expanding to unintended patterns. - normQ = normQ.replace(/%/g, '\\%').replace(/_/g, '\\_'); + normQ = normQ.replace(/%/g, String.raw`\%`).replace(/_/g, String.raw`\_`); // Parameterised placeholders ($1, $2) prevent SQL injection. const rows = await prisma.$queryRawUnsafe>( `SELECT DISTINCT p.id diff --git a/proxy.ts b/proxy.ts index 6b0f56a7..a555f741 100644 --- a/proxy.ts +++ b/proxy.ts @@ -125,9 +125,7 @@ function unauthorizedResponse(request: NextRequest, pathname: string) { export const config = { matcher: [ - // Pages and non-api paths (including /img/ static assets) - '/((?!api|_next/static|_next/image|favicon\\.ico|icon\\.svg|.*\\.png$).*)', - // Protected API routes (explicitly added since the regex above excludes /api) + '/((?!api|_next/static|_next/image|favicon[.]ico|icon[.]svg|.*[.]png$).*)', '/api/admin/:path*', '/api/orders/:path*', '/api/users/:path*', From 16af4d783c16525b1bf3685a19cd32bb424ace19 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 6 Jul 2026 19:04:23 +0200 Subject: [PATCH 2/4] better lint rules phase 2 --- app/[locale]/cart/design-preview.tsx | 2 +- app/[locale]/layout.tsx | 4 ++-- app/[locale]/profile/page.tsx | 2 +- composition-root/container.ts | 9 ++++----- eslint.config.mjs | 1 - .../auth/infrastructure/memory-used-reset-token-store.ts | 2 +- modules/presentation/components/design-preview.tsx | 2 +- .../uploads/infrastructure/prisma-upload-repository.ts | 2 +- .../application/use-cases/forgot-password-use-case.ts | 2 +- shared/infrastructure/used-reset-token-store.ts | 2 +- shared/layout/login-modal.tsx | 4 ++-- 11 files changed, 15 insertions(+), 17 deletions(-) diff --git a/app/[locale]/cart/design-preview.tsx b/app/[locale]/cart/design-preview.tsx index 3bc53c6e..f7cb82af 100644 --- a/app/[locale]/cart/design-preview.tsx +++ b/app/[locale]/cart/design-preview.tsx @@ -87,7 +87,7 @@ function loadImage(src: string): Promise { return new Promise((resolve) => { const img = new Image(); img.crossOrigin = 'anonymous'; - img.onload = () => resolve(img); + img.addEventListener('load', () => resolve(img)); img.onerror = () => resolve(null); img.src = src; }); diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 4788e69e..52abbba2 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -42,9 +42,9 @@ export async function generateMetadata({ const baseUrl = APP_BASE_URL; const alternates: Record = {}; - ['es', 'cat'].forEach((lang) => { + for (const lang of ['es', 'cat']) { alternates[lang] = `${baseUrl}/${lang}`; - }); + } return { metadataBase: new URL(baseUrl), diff --git a/app/[locale]/profile/page.tsx b/app/[locale]/profile/page.tsx index 2ac801d0..59e54933 100644 --- a/app/[locale]/profile/page.tsx +++ b/app/[locale]/profile/page.tsx @@ -136,7 +136,7 @@ export default function ProfilePage() { throw new Error(data.error || 'Failed to delete account'); } // Redirect to home after soft-delete - window.location.href = '/'; + window.location.assign('/'); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Failed to delete account'); } finally { diff --git a/composition-root/container.ts b/composition-root/container.ts index 14c92b3a..3784f13b 100644 --- a/composition-root/container.ts +++ b/composition-root/container.ts @@ -155,11 +155,10 @@ let _searchHistoryEventsSubscribed = false; export function initContainer(): void { // --- EmailSender: env-dependent (Brevo in production, console otherwise) --- if (!_emailSender) { - if (process.env.NODE_ENV === 'production') { - _emailSender = new BrevoEmailSender(); - } else { - _emailSender = new ConsoleEmailSender(); - } + _emailSender = + process.env.NODE_ENV === 'production' + ? new BrevoEmailSender() + : new ConsoleEmailSender(); } // --- OutboxRepository: single Prisma adapter works in every env --- diff --git a/eslint.config.mjs b/eslint.config.mjs index 6bf43ea1..dbe994c6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -187,7 +187,6 @@ export default [ 'unicorn/import-style': 'off', 'sonarjs/no-unused-vars': 'off', 'unicorn/no-declarations-before-early-exit': 'off', - //'unicorn/prefer-string-raw': 'off', 'unicorn/no-for-each': 'off', 'unicorn/prefer-location-assign': 'off', diff --git a/modules/auth/infrastructure/memory-used-reset-token-store.ts b/modules/auth/infrastructure/memory-used-reset-token-store.ts index 0ec1717a..01443d93 100644 --- a/modules/auth/infrastructure/memory-used-reset-token-store.ts +++ b/modules/auth/infrastructure/memory-used-reset-token-store.ts @@ -34,6 +34,6 @@ export class MemoryUsedResetTokenStore implements UsedResetTokenStorePort { /** Mark a token as used with optional expiry for auto-cleanup. */ markTokenUsed(jti: string, exp?: number): void { this.purgeExpired(); - this.usedTokens.set(jti, exp ?? Date.now() + 3600_000); // default 1h TTL + this.usedTokens.set(jti, exp ?? Date.now() + 3_600_000); // default 1h TTL } } diff --git a/modules/presentation/components/design-preview.tsx b/modules/presentation/components/design-preview.tsx index 3bc53c6e..f7cb82af 100644 --- a/modules/presentation/components/design-preview.tsx +++ b/modules/presentation/components/design-preview.tsx @@ -87,7 +87,7 @@ function loadImage(src: string): Promise { return new Promise((resolve) => { const img = new Image(); img.crossOrigin = 'anonymous'; - img.onload = () => resolve(img); + img.addEventListener('load', () => resolve(img)); img.onerror = () => resolve(null); img.src = src; }); diff --git a/modules/uploads/infrastructure/prisma-upload-repository.ts b/modules/uploads/infrastructure/prisma-upload-repository.ts index 3d3070e0..2a915c80 100644 --- a/modules/uploads/infrastructure/prisma-upload-repository.ts +++ b/modules/uploads/infrastructure/prisma-upload-repository.ts @@ -54,7 +54,7 @@ export class PrismaUploadRepository implements UploadRepository { `findPendingOlderThan: hours must be a positive finite number, got ${hours}`, ); } - const cutoff = new Date(Date.now() - hours * 3600_000); + const cutoff = new Date(Date.now() - hours * 3_600_000); const rows = await prisma.upload.findMany({ where: { status: UploadStatus.PENDING, diff --git a/modules/users/application/use-cases/forgot-password-use-case.ts b/modules/users/application/use-cases/forgot-password-use-case.ts index 3a7d2e90..3f1cf914 100644 --- a/modules/users/application/use-cases/forgot-password-use-case.ts +++ b/modules/users/application/use-cases/forgot-password-use-case.ts @@ -7,7 +7,7 @@ export interface ForgotPasswordDTO { } /** Token validity: 1 hour in milliseconds. */ -const TOKEN_TTL_MS = 3600_000; +const TOKEN_TTL_MS = 3_600_000; export class ForgotPasswordUseCase { constructor( diff --git a/shared/infrastructure/used-reset-token-store.ts b/shared/infrastructure/used-reset-token-store.ts index a2bf86a2..108425e6 100644 --- a/shared/infrastructure/used-reset-token-store.ts +++ b/shared/infrastructure/used-reset-token-store.ts @@ -32,5 +32,5 @@ export function isTokenUsed(jti: string): boolean { */ export function markTokenUsed(jti: string, exp?: number): void { purgeExpired(); - usedTokens.set(jti, exp ?? Date.now() + 3600_000); // default 1h TTL + usedTokens.set(jti, exp ?? Date.now() + 3_600_000); // default 1h TTL } diff --git a/shared/layout/login-modal.tsx b/shared/layout/login-modal.tsx index 7c64ceb1..dd0b21b4 100644 --- a/shared/layout/login-modal.tsx +++ b/shared/layout/login-modal.tsx @@ -45,9 +45,9 @@ export function LoginModal({ isOpen, onClose }: LoginModalProps) { const role = session?.user?.role; const locale = window.location.pathname.split('/')[1] ?? 'es'; if (role === 'ADMIN') { - window.location.href = `/${locale}/admin/sellers`; + window.location.assign(`/${locale}/admin/sellers`); } else if (role === 'DESIGNER') { - window.location.href = `/${locale}/seller/products`; + window.location.assign(`/${locale}/seller/products`); } } } catch (err: unknown) { From e8711207dbbf03ad6be4e58c643b9fdf12283bfe Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 6 Jul 2026 19:45:25 +0200 Subject: [PATCH 3/4] better lint rules phase 3 --- app/[locale]/auth/reset-password/page.tsx | 24 +++++++------- app/[locale]/cart/design-preview.tsx | 2 +- app/[locale]/cart/page.tsx | 4 +-- .../products/[id]/customization-form.tsx | 1 - .../products/[id]/photo-upload-field.tsx | 2 +- app/[locale]/profile/page.tsx | 8 ++--- .../uploads/local/[...storageKey]/route.ts | 18 +++++------ eslint.config.mjs | 14 +------- modules/cart/application/add-item-to-cart.ts | 3 +- modules/cart/application/checkout-cart.ts | 22 ++++++------- modules/cart/domain/value-objects/quantity.ts | 2 +- .../components/add-to-cart-button.tsx | 10 +++--- .../presentation/components/cart-icon.tsx | 2 +- .../create-customer-customization.ts | 22 +++++++------ .../value-objects/customization-options.ts | 10 +++--- modules/events/domain/event-registry.ts | 2 ++ .../list-seller-orders-use-case.ts | 3 +- .../components/design-preview.tsx | 2 +- .../list-seller-products-use-case.ts | 3 +- .../use-cases/register-user-use-case.ts | 17 +++++----- .../infrastructure/prisma-user-repository.ts | 18 +++++------ scripts/optimize-sprites.mjs | 32 ++++++++++++------- shared/kernel/config.ts | 6 +++- shared/kernel/domain/value-objects/email.ts | 2 +- .../components/file-upload-dropzone.tsx | 4 +-- shared/ui/file-upload-dropzone.tsx | 4 +-- tests/doubles/memory-storage-adapter.ts | 1 + tests/e2e/admin/credentials.ts | 3 ++ .../[id]/customization-experience.test.tsx | 1 + .../[id]/customization-preview.test.tsx | 1 + .../seller/products/product-form.test.tsx | 1 + 31 files changed, 126 insertions(+), 118 deletions(-) diff --git a/app/[locale]/auth/reset-password/page.tsx b/app/[locale]/auth/reset-password/page.tsx index 13921c16..82796180 100644 --- a/app/[locale]/auth/reset-password/page.tsx +++ b/app/[locale]/auth/reset-password/page.tsx @@ -25,6 +25,18 @@ export default function ResetPasswordPage() { const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); + if (!token) { + return ( + +

{dict.auth.resetPasswordTitle}

+ + + {dict.auth.requestNewLink} + +
+ ); + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -61,18 +73,6 @@ export default function ResetPasswordPage() { } }; - if (!token) { - return ( - -

{dict.auth.resetPasswordTitle}

- - - {dict.auth.requestNewLink} - -
- ); - } - return (

{dict.auth.resetPasswordTitle}

diff --git a/app/[locale]/cart/design-preview.tsx b/app/[locale]/cart/design-preview.tsx index f7cb82af..33876fe7 100644 --- a/app/[locale]/cart/design-preview.tsx +++ b/app/[locale]/cart/design-preview.tsx @@ -88,7 +88,7 @@ function loadImage(src: string): Promise { const img = new Image(); img.crossOrigin = 'anonymous'; img.addEventListener('load', () => resolve(img)); - img.onerror = () => resolve(null); + img.addEventListener('error', () => resolve(null)); img.src = src; }); } diff --git a/app/[locale]/cart/page.tsx b/app/[locale]/cart/page.tsx index 7b103d2a..441893f5 100644 --- a/app/[locale]/cart/page.tsx +++ b/app/[locale]/cart/page.tsx @@ -45,9 +45,9 @@ export default async function CartPage({ productIds.map((id) => productRepository.findById(id, locale)), ); const productMap = new Map(); - products.forEach((p) => { + for (const p of products) { if (p) productMap.set(p.id, p); - }); + } items = cart.items.map((item) => { const product = productMap.get(item.productId.value); diff --git a/app/[locale]/products/[id]/customization-form.tsx b/app/[locale]/products/[id]/customization-form.tsx index 9cf38260..e9ed8304 100644 --- a/app/[locale]/products/[id]/customization-form.tsx +++ b/app/[locale]/products/[id]/customization-form.tsx @@ -60,7 +60,6 @@ export function CustomizationForm({ const config = ProductCustomizationConfig.fromJson(customizationConfig); const textErrorId = errors.text ? 'customization-text-error' : undefined; - const _colorErrorId = errors.color ? 'customization-color-error' : undefined; const sizeErrorId = errors.size ? 'customization-size-error' : undefined; return ( diff --git a/app/[locale]/products/[id]/photo-upload-field.tsx b/app/[locale]/products/[id]/photo-upload-field.tsx index 87abbcbe..fef0d6a0 100644 --- a/app/[locale]/products/[id]/photo-upload-field.tsx +++ b/app/[locale]/products/[id]/photo-upload-field.tsx @@ -55,7 +55,7 @@ export function PhotoUploadField({ setError(null); startTransition(() => { - void onUpload(file).then((result) => { + onUpload(file).then((result) => { onUploaded?.(result); }); }); diff --git a/app/[locale]/profile/page.tsx b/app/[locale]/profile/page.tsx index 59e54933..a46ea82f 100644 --- a/app/[locale]/profile/page.tsx +++ b/app/[locale]/profile/page.tsx @@ -96,6 +96,10 @@ export default function ProfilePage() { }; }, [status, locale, router]); + if (status === 'loading' || loading) { + return
{dict.common.loading}
; + } + const handleSave = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); @@ -145,10 +149,6 @@ export default function ProfilePage() { } }; - if (status === 'loading' || loading) { - return
{dict.common.loading}
; - } - return (
{dict.common.backToHome} diff --git a/app/api/uploads/local/[...storageKey]/route.ts b/app/api/uploads/local/[...storageKey]/route.ts index 118b5739..640e4427 100644 --- a/app/api/uploads/local/[...storageKey]/route.ts +++ b/app/api/uploads/local/[...storageKey]/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; async function getStorageRoot(): Promise { - const { join } = await import('node:path'); + const path = await import('node:path'); return ( process.env.LOCAL_UPLOAD_STORAGE_DIR ?? - join(process.cwd(), 'tmp', 'uploads') + path.join(process.cwd(), 'tmp', 'uploads') ); } @@ -12,7 +12,7 @@ async function resolveStoragePath( storageKey: string[], root: string, ): Promise { - const { resolve, relative, isAbsolute } = await import('node:path'); + const path = await import('node:path'); const decodedKey = decodeURIComponent(storageKey.join('/')); if ( @@ -24,14 +24,14 @@ async function resolveStoragePath( } const segments = decodedKey.split('/').filter(Boolean); - const rawPath = resolve(root, ...segments); - const rawRelative = relative(root, rawPath); - if (rawRelative.startsWith('..') || isAbsolute(rawRelative)) { + const rawPath = path.resolve(root, ...segments); + const rawRelative = path.relative(root, rawPath); + if (rawRelative.startsWith('..') || path.isAbsolute(rawRelative)) { return null; } const sanitized = segments.map((s) => s.replace(/[:*?"<>|]/g, '_')); - const filePath = resolve(root, ...sanitized); + const filePath = path.resolve(root, ...sanitized); return filePath; } @@ -56,7 +56,7 @@ export async function PUT( } const { mkdir, writeFile } = await import('node:fs/promises'); - const { dirname } = await import('node:path'); + const path = await import('node:path'); const { storageKey } = await context.params; const root = await getStorageRoot(); @@ -65,7 +65,7 @@ export async function PUT( return NextResponse.json({ error: 'Invalid storage key' }, { status: 400 }); } - await mkdir(dirname(filePath), { recursive: true }); + await mkdir(path.dirname(filePath), { recursive: true }); const body = Buffer.from(await request.arrayBuffer()); await writeFile(filePath, body); diff --git a/eslint.config.mjs b/eslint.config.mjs index dbe994c6..fd5f2cbe 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -150,6 +150,7 @@ export default [ 'unicorn/no-null': 'off', // muchos proyectos usan null intencionalmente (ej. React) 'unicorn/prefer-module': 'off', // si tenéis algún archivo CJS (configs, scripts) 'unicorn/name-replacements': 'off', + 'unicorn/import-style': 'off', 'unicorn/no-top-level-assignment-in-function': 'off', 'unicorn/consistent-class-member-order': 'off', @@ -176,19 +177,6 @@ export default [ 'sonarjs/no-nested-template-literals': 'off', 'unicorn/no-nested-ternary': 'off', 'unicorn/no-array-sort': 'off', - 'unicorn/prefer-number-is-safe-integer': 'off', - 'unicorn/prefer-ternary': 'off', - 'sonarjs/no-useless-react-setstate': 'off', - 'sonarjs/no-hardcoded-passwords': 'off', - 'unicorn/prefer-add-event-listener': 'off', - 'sonarjs/super-linear-regex': 'off', - 'unicorn/numeric-separators-style': 'off', - 'sonarjs/void-use': 'off', - 'unicorn/import-style': 'off', - 'sonarjs/no-unused-vars': 'off', - 'unicorn/no-declarations-before-early-exit': 'off', - 'unicorn/no-for-each': 'off', - 'unicorn/prefer-location-assign': 'off', // SonarJS: ajustar el umbral de complejidad cognitiva si el default es muy estricto // 'sonarjs/cognitive-complexity': ['warn', 15], diff --git a/modules/cart/application/add-item-to-cart.ts b/modules/cart/application/add-item-to-cart.ts index 33df2e3c..6e0aee97 100644 --- a/modules/cart/application/add-item-to-cart.ts +++ b/modules/cart/application/add-item-to-cart.ts @@ -170,7 +170,7 @@ export class AddItemToCart { private async validateCustomizations( customizationIdList: string[], productId: string, - sellerId: string, + _sellerId: string, ): Promise { const snapshots = await this.customizationLookup.findByIds(customizationIdList); @@ -197,7 +197,6 @@ export class AddItemToCart { // their sellerId from the Product. If productId matches, the // sellerId is guaranteed to match (enforced at customization // creation time by the customizations module). - void sellerId; } } diff --git a/modules/cart/application/checkout-cart.ts b/modules/cart/application/checkout-cart.ts index 565d7bff..4f241ca8 100644 --- a/modules/cart/application/checkout-cart.ts +++ b/modules/cart/application/checkout-cart.ts @@ -95,11 +95,7 @@ export class CheckoutCart { ) {} async preview(userId: string): Promise { - const { cart, totals } = await this.buildTotals(userId, false); - // Touch the unused cart ref so TS doesn't complain about a return-only - // branch — the spec ties the preview to the cart's contents and we - // want the call to also serve as a validation gate. - void cart; + const { totals } = await this.buildTotals(userId, false); return totals; } @@ -269,14 +265,14 @@ export class CheckoutCart { // Persist the updated snapshots (if any) so the cart reflects the // new prices. Status stays ACTIVE — checkout hasn't run yet. - let liveCart = cart; - if (acceptPriceChanges && priceChanges.length > 0) { - liveCart = await this.cartRepository.save({ - ...cart, - items: updatedItems, - updatedAt: new Date(), - }); - } + const liveCart = + acceptPriceChanges && priceChanges.length > 0 + ? await this.cartRepository.save({ + ...cart, + items: updatedItems, + updatedAt: new Date(), + }) + : cart; // Compute totals from the live items (so acceptPriceChanges reflects // the updated snapshot prices in subtotal/discount/total). diff --git a/modules/cart/domain/value-objects/quantity.ts b/modules/cart/domain/value-objects/quantity.ts index 4c739b28..f31e31e1 100644 --- a/modules/cart/domain/value-objects/quantity.ts +++ b/modules/cart/domain/value-objects/quantity.ts @@ -20,7 +20,7 @@ export class Quantity { } static create(amount: number): Quantity { - if (!Number.isFinite(amount) || !Number.isInteger(amount)) { + if (!Number.isSafeInteger(amount)) { throw new InvalidQuantityError( `Quantity must be a finite integer, got: ${amount}`, ); diff --git a/modules/cart/presentation/components/add-to-cart-button.tsx b/modules/cart/presentation/components/add-to-cart-button.tsx index e04b3319..2acdce3e 100644 --- a/modules/cart/presentation/components/add-to-cart-button.tsx +++ b/modules/cart/presentation/components/add-to-cart-button.tsx @@ -492,6 +492,7 @@ export function AddToCartButton({ if (!isInCart || currentQuantity >= MAX_QUANTITY) return; const newQty = currentQuantity + 1; if (isAuthenticated && cartItemInfo) { + const prevCartItemInfo = cartItemInfo; setCartItemInfo({ ...cartItemInfo, quantity: newQty }); try { const res = await fetch(`/api/cart/items/${cartItemInfo.cartItemId}`, { @@ -500,12 +501,12 @@ export function AddToCartButton({ body: JSON.stringify({ quantity: newQty }), }); if (!res.ok) { - setCartItemInfo(cartItemInfo); + setCartItemInfo(prevCartItemInfo); return; } dispatchCartUpdated(); } catch { - setCartItemInfo(cartItemInfo); + setCartItemInfo(prevCartItemInfo); } } else if (guestMatch?.id) { updateItemQuantity(guestMatch.id, newQty); @@ -525,6 +526,7 @@ export function AddToCartButton({ const newQty = currentQuantity - 1; if (isAuthenticated && cartItemInfo) { + const prevCartItemInfo = cartItemInfo; setCartItemInfo({ ...cartItemInfo, quantity: newQty }); try { const res = await fetch(`/api/cart/items/${cartItemInfo.cartItemId}`, { @@ -533,12 +535,12 @@ export function AddToCartButton({ body: JSON.stringify({ quantity: newQty }), }); if (!res.ok) { - setCartItemInfo(cartItemInfo); + setCartItemInfo(prevCartItemInfo); return; } dispatchCartUpdated(); } catch { - setCartItemInfo(cartItemInfo); + setCartItemInfo(prevCartItemInfo); } } else if (guestMatch?.id) { updateItemQuantity(guestMatch.id, newQty); diff --git a/modules/cart/presentation/components/cart-icon.tsx b/modules/cart/presentation/components/cart-icon.tsx index 8ce8812c..38304e86 100644 --- a/modules/cart/presentation/components/cart-icon.tsx +++ b/modules/cart/presentation/components/cart-icon.tsx @@ -53,7 +53,7 @@ export function CartIcon({ alt }: CartIconProps) { useEffect(() => { if (!isAuthenticated) return; window.addEventListener(CART_UPDATED_EVENT, handleCartUpdated); - void Promise.try(fetchCount); + Promise.try(fetchCount); return () => { abortRef.current?.abort(); diff --git a/modules/customizations/application/create-customer-customization.ts b/modules/customizations/application/create-customer-customization.ts index 71400ad7..28eee752 100644 --- a/modules/customizations/application/create-customer-customization.ts +++ b/modules/customizations/application/create-customer-customization.ts @@ -57,16 +57,6 @@ export class CreateCustomerCustomization { config: ProductCustomizationConfig, ): void { const hasText = dto.text !== undefined && dto.text !== null; - const hasImage = dto.imageUrl !== undefined && dto.imageUrl !== null; - const hasDesignPosition = - dto.designPosition !== undefined && dto.designPosition !== null; - // A canvas-only customization is also a "has image" signal — the - // mockup canvas embeds imageUrl inside designPosition, so a - // canvas-only draft must satisfy the photo requirement. - const hasImageForCapability = hasImage || hasDesignPosition; - const hasStyle = - (dto.color !== undefined && dto.color !== null) || - (dto.size !== undefined && dto.size !== null); if (hasText && !config.allowsText()) { throw new ValidationError( @@ -75,6 +65,14 @@ export class CreateCustomerCustomization { ); } + const hasImage = dto.imageUrl !== undefined && dto.imageUrl !== null; + const hasDesignPosition = + dto.designPosition !== undefined && dto.designPosition !== null; + // A canvas-only customization is also a "has image" signal — the + // mockup canvas embeds imageUrl inside designPosition, so a + // canvas-only draft must satisfy the photo requirement. + const hasImageForCapability = hasImage || hasDesignPosition; + if (hasImageForCapability && !config.allowsPhoto()) { throw new ValidationError( 'This product does not support photo customization', @@ -82,6 +80,10 @@ export class CreateCustomerCustomization { ); } + const hasStyle = + (dto.color !== undefined && dto.color !== null) || + (dto.size !== undefined && dto.size !== null); + if (hasStyle && !config.allowsStyleOptions()) { throw new ValidationError( 'This product does not support color or size options', diff --git a/modules/customizations/domain/value-objects/customization-options.ts b/modules/customizations/domain/value-objects/customization-options.ts index 0603f078..4c89e4eb 100644 --- a/modules/customizations/domain/value-objects/customization-options.ts +++ b/modules/customizations/domain/value-objects/customization-options.ts @@ -55,15 +55,15 @@ export class CustomizationOptions { }): CustomizationOptions { // Normalize null to undefined — DB fields return string | null. 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; 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; + if (color !== undefined) { if (color.trim().length === 0) { throw new Error('Customization color cannot be empty if provided'); @@ -89,6 +89,8 @@ export class CustomizationOptions { } } + const designPosition = data.designPosition ?? undefined; + if ( designPosition !== undefined && designPosition !== null && diff --git a/modules/events/domain/event-registry.ts b/modules/events/domain/event-registry.ts index a5c07f5d..8fac1ddd 100644 --- a/modules/events/domain/event-registry.ts +++ b/modules/events/domain/event-registry.ts @@ -27,8 +27,10 @@ export const GlobalEvents = { /** User account deleted */ USER_DELETED: 'user.deleted', /** Password changed by authenticated user */ + // eslint-disable-next-line sonarjs/no-hardcoded-passwords PASSWORD_CHANGED: 'password.changed', /** Password reset via forgot-password flow */ + // eslint-disable-next-line sonarjs/no-hardcoded-passwords PASSWORD_RESET: 'password.reset', /** New seller created (initial status: active) */ SELLER_CREATED: 'seller.created', diff --git a/modules/orders/application/list-seller-orders-use-case.ts b/modules/orders/application/list-seller-orders-use-case.ts index 20dd8c2d..90a514c1 100644 --- a/modules/orders/application/list-seller-orders-use-case.ts +++ b/modules/orders/application/list-seller-orders-use-case.ts @@ -26,7 +26,8 @@ export class ListSellerOrdersUseCase { throw new NotFoundError('Seller not found'); } - const { userId: _userId, ...filter } = dto; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- destructuring to exclude userId + const { userId, ...filter } = dto; return this.orderRepository.findPaginated( { ...filter, diff --git a/modules/presentation/components/design-preview.tsx b/modules/presentation/components/design-preview.tsx index f7cb82af..33876fe7 100644 --- a/modules/presentation/components/design-preview.tsx +++ b/modules/presentation/components/design-preview.tsx @@ -88,7 +88,7 @@ function loadImage(src: string): Promise { const img = new Image(); img.crossOrigin = 'anonymous'; img.addEventListener('load', () => resolve(img)); - img.onerror = () => resolve(null); + img.addEventListener('error', () => resolve(null)); img.src = src; }); } diff --git a/modules/sellers/application/use-cases/list-seller-products-use-case.ts b/modules/sellers/application/use-cases/list-seller-products-use-case.ts index 3c023647..dba672a2 100644 --- a/modules/sellers/application/use-cases/list-seller-products-use-case.ts +++ b/modules/sellers/application/use-cases/list-seller-products-use-case.ts @@ -40,7 +40,8 @@ export class ListSellerProductsUseCase { throw new NotFoundError('Seller not found'); } - const { userId: _userId, ...filter } = dto; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- destructuring to exclude userId + const { userId, ...filter } = dto; return this.productQuery.execute({ ...filter, diff --git a/modules/users/application/use-cases/register-user-use-case.ts b/modules/users/application/use-cases/register-user-use-case.ts index 01427236..b52e6c19 100644 --- a/modules/users/application/use-cases/register-user-use-case.ts +++ b/modules/users/application/use-cases/register-user-use-case.ts @@ -60,15 +60,14 @@ export class RegisterUserUseCase { const userId = UserId.create(crypto.randomUUID()); const roleId = RoleId.create('CUSTOMER'); - let address: Address | null = null; - if (dto.address) { - address = Address.create( - dto.address.street, - dto.address.city, - dto.address.postalCode, - dto.address.country, - ); - } + const address = dto.address + ? Address.create( + dto.address.street, + dto.address.city, + dto.address.postalCode, + dto.address.country, + ) + : null; // 5. Save user const now = new Date(); diff --git a/modules/users/infrastructure/prisma-user-repository.ts b/modules/users/infrastructure/prisma-user-repository.ts index 9c496c68..d5317dfb 100644 --- a/modules/users/infrastructure/prisma-user-repository.ts +++ b/modules/users/infrastructure/prisma-user-repository.ts @@ -26,20 +26,18 @@ export function toDomain(user: { }): UserEntity { if (!user.email) throw new Error('User email is required'); - let address: Address | null = null; - if ( + const address = user.addressStreet && user.addressCity && user.addressPostalCode && user.addressCountry - ) { - address = Address.create( - user.addressStreet, - user.addressCity, - user.addressPostalCode, - user.addressCountry, - ); - } + ? Address.create( + user.addressStreet, + user.addressCity, + user.addressPostalCode, + user.addressCountry, + ) + : null; return { userId: UserId.create(user.id), diff --git a/scripts/optimize-sprites.mjs b/scripts/optimize-sprites.mjs index 17ba409c..9add4cbd 100644 --- a/scripts/optimize-sprites.mjs +++ b/scripts/optimize-sprites.mjs @@ -9,14 +9,14 @@ * ../shared/presentation/sprites.css (utility classes for sizing) */ import { readFileSync, writeFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { optimize } from 'svgo'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ICONS_DIR = join(__dirname, '../devresources/icons'); -const OUTPUT_SVG = join(__dirname, '../public/img/icons/sprites.svg'); -const OUTPUT_CSS = join(__dirname, '../shared/presentation/sprites.css'); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ICONS_DIR = path.join(__dirname, '../devresources/icons'); +const OUTPUT_SVG = path.join(__dirname, '../public/img/icons/sprites.svg'); +const OUTPUT_CSS = path.join(__dirname, '../shared/presentation/sprites.css'); // Icon name mapping: filename -> class/id name // Files are read directly from the /icons directory at repo root @@ -49,10 +49,18 @@ function resolveClassFills(svgContent) { const cssText = match[1]; const classMap = {}; - const ruleRegex = /\.(\S+)\s*\{[^}]*?fill:\s*([^;}]+)[^}]*?\}/g; - let m; - while ((m = ruleRegex.exec(cssText)) !== null) { - classMap[m[1]] = m[2].trim(); + for (const rule of cssText.split('}')) { + const dotIdx = rule.indexOf('.'); + const braceIdx = rule.indexOf('{'); + if (dotIdx === -1 || braceIdx === -1 || dotIdx > braceIdx) continue; + const className = rule + .slice(dotIdx + 1, braceIdx) + .trim() + .split(/\s+/)[0]; + const fillMatch = rule.slice(braceIdx).match(/fill:\s*([^;}\s]+)/); + if (className && fillMatch) { + classMap[className] = fillMatch[1].trim(); + } } if (Object.keys(classMap).length === 0) return svgContent; @@ -143,11 +151,11 @@ writeFileSync(OUTPUT_CSS, cssContent, 'utf8'); // Print summary console.log(`\n✓ Sprite generated: ${OUTPUT_SVG}`); console.log(` ${icons.length} icons optimized`); -icons.forEach((i) => +for (const i of icons) { console.log( ` • ${i.file.padEnd(22)} → icon-${i.name.padEnd(10)} ${i.originalSize}B → ${i.optimizedSize}B`, - ), -); + ); +} const totalOriginal = icons.reduce((s, i) => s + i.originalSize, 0); const totalOptimized = icons.reduce((s, i) => s + i.optimizedSize, 0); console.log( diff --git a/shared/kernel/config.ts b/shared/kernel/config.ts index 736103c7..ccc47ddf 100644 --- a/shared/kernel/config.ts +++ b/shared/kernel/config.ts @@ -22,7 +22,11 @@ if (!RAW_BASE_URL) { } // Strip trailing slashes so link composition is predictable. -export const APP_BASE_URL: string = RAW_BASE_URL.replace(/\/+$/, ''); +export const APP_BASE_URL: string = (() => { + let url = RAW_BASE_URL; + while (url.length > 0 && url.at(-1) === '/') url = url.slice(0, -1); + return url; +})(); export function getBaseUrl(): string { return APP_BASE_URL; diff --git a/shared/kernel/domain/value-objects/email.ts b/shared/kernel/domain/value-objects/email.ts index c36ea83e..189789ad 100644 --- a/shared/kernel/domain/value-objects/email.ts +++ b/shared/kernel/domain/value-objects/email.ts @@ -16,7 +16,7 @@ export class Email { throw new Error('Email must be at most 254 characters'); } - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const emailRegex = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/; if (!emailRegex.test(trimmed)) { throw new Error('Invalid email format'); } diff --git a/shared/presentation/components/file-upload-dropzone.tsx b/shared/presentation/components/file-upload-dropzone.tsx index dd72264a..22e73ac0 100644 --- a/shared/presentation/components/file-upload-dropzone.tsx +++ b/shared/presentation/components/file-upload-dropzone.tsx @@ -157,9 +157,9 @@ function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) { const value = bytes / 1024; - return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} KB`; + return `${Number.isSafeInteger(value) ? value.toFixed(0) : value.toFixed(1)} KB`; } const value = bytes / (1024 * 1024); - return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} MB`; + return `${Number.isSafeInteger(value) ? value.toFixed(0) : value.toFixed(1)} MB`; } diff --git a/shared/ui/file-upload-dropzone.tsx b/shared/ui/file-upload-dropzone.tsx index dd72264a..22e73ac0 100644 --- a/shared/ui/file-upload-dropzone.tsx +++ b/shared/ui/file-upload-dropzone.tsx @@ -157,9 +157,9 @@ function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) { const value = bytes / 1024; - return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} KB`; + return `${Number.isSafeInteger(value) ? value.toFixed(0) : value.toFixed(1)} KB`; } const value = bytes / (1024 * 1024); - return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} MB`; + return `${Number.isSafeInteger(value) ? value.toFixed(0) : value.toFixed(1)} MB`; } diff --git a/tests/doubles/memory-storage-adapter.ts b/tests/doubles/memory-storage-adapter.ts index 2fb189e6..677435f8 100644 --- a/tests/doubles/memory-storage-adapter.ts +++ b/tests/doubles/memory-storage-adapter.ts @@ -28,6 +28,7 @@ export class MemoryStorageAdapter implements StoragePort { } getPublicUrl(key: string): string { + // eslint-disable-next-line sonarjs/super-linear-regex -- simple trailing-slash trim return `${this.publicDomain.replace(/\/+$/, '')}/${key}`; } diff --git a/tests/e2e/admin/credentials.ts b/tests/e2e/admin/credentials.ts index 3d750d04..ddc05c1b 100644 --- a/tests/e2e/admin/credentials.ts +++ b/tests/e2e/admin/credentials.ts @@ -1,6 +1,9 @@ /** Credentials for seeded test users. */ export const TEST_USERS = { + // eslint-disable-next-line sonarjs/no-hardcoded-passwords -- E2E test credentials admin: { email: 'admin@728store.com', password: 'Admin123!' }, + customer: { email: 'test@test.com', password: 'Test123!' }, + // eslint-disable-next-line sonarjs/no-hardcoded-passwords -- E2E test credentials designer: { email: 'designer@test.com', password: 'Designer123!' }, } as const; diff --git a/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx b/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx index f8771008..6e8b0862 100644 --- a/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx +++ b/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx @@ -18,6 +18,7 @@ vi.mock('@/modules/cart/presentation/components/add-to-cart-button', () => ({ vi.mock('next/image', () => ({ default: (props: ImgHTMLAttributes) => { + // eslint-disable-next-line sonarjs/no-unused-vars -- stripping unoptimized prop const { unoptimized: _unoptimized, ...rest } = props as ImgHTMLAttributes & { unoptimized?: boolean; diff --git a/tests/unit/app/[locale]/products/[id]/customization-preview.test.tsx b/tests/unit/app/[locale]/products/[id]/customization-preview.test.tsx index 3cab8369..b50d8a83 100644 --- a/tests/unit/app/[locale]/products/[id]/customization-preview.test.tsx +++ b/tests/unit/app/[locale]/products/[id]/customization-preview.test.tsx @@ -6,6 +6,7 @@ import { CustomizationPreview } from '@/app/[locale]/products/[id]/customization vi.mock('next/image', () => ({ default: (props: ImgHTMLAttributes) => { + // eslint-disable-next-line sonarjs/no-unused-vars -- stripping unoptimized prop const { unoptimized: _unoptimized, ...rest } = props as ImgHTMLAttributes & { unoptimized?: boolean; diff --git a/tests/unit/app/[locale]/seller/products/product-form.test.tsx b/tests/unit/app/[locale]/seller/products/product-form.test.tsx index 125d9230..768529a5 100644 --- a/tests/unit/app/[locale]/seller/products/product-form.test.tsx +++ b/tests/unit/app/[locale]/seller/products/product-form.test.tsx @@ -10,6 +10,7 @@ const refreshMock = vi.fn(); vi.mock('next/image', () => ({ default: (props: ImgHTMLAttributes) => { + // eslint-disable-next-line sonarjs/no-unused-vars -- stripping unoptimized prop const { unoptimized: _unoptimized, ...rest } = props as ImgHTMLAttributes & { unoptimized?: boolean; From ea2c4ad667f7dc87d6ba8b5c3256cd6d55a59cac Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 6 Jul 2026 20:01:44 +0200 Subject: [PATCH 4/4] fix comments ci --- app/api/uploads/local/[...storageKey]/route.ts | 6 +++++- scripts/optimize-sprites.mjs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/api/uploads/local/[...storageKey]/route.ts b/app/api/uploads/local/[...storageKey]/route.ts index 640e4427..654441ca 100644 --- a/app/api/uploads/local/[...storageKey]/route.ts +++ b/app/api/uploads/local/[...storageKey]/route.ts @@ -26,7 +26,11 @@ async function resolveStoragePath( const rawPath = path.resolve(root, ...segments); const rawRelative = path.relative(root, rawPath); - if (rawRelative.startsWith('..') || path.isAbsolute(rawRelative)) { + if ( + rawRelative === '..' || + rawRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(rawRelative) + ) { return null; } diff --git a/scripts/optimize-sprites.mjs b/scripts/optimize-sprites.mjs index 9add4cbd..f401562a 100644 --- a/scripts/optimize-sprites.mjs +++ b/scripts/optimize-sprites.mjs @@ -77,7 +77,7 @@ function resolveClassFills(svgContent) { // Optimize each icon and extract viewBox content const icons = files.map((file) => { - const raw = readFileSync(join(ICONS_DIR, file), 'utf8'); + const raw = readFileSync(path.join(ICONS_DIR, file), 'utf8'); // Resolve class-based fills to inline fills BEFORE optimization, // so SVGO doesn't strip the