{dict.common.backToHome}
diff --git a/app/api/uploads/local/[...storageKey]/route.ts b/app/api/uploads/local/[...storageKey]/route.ts
index 118b5739..654441ca 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,18 @@ 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 === '..' ||
+ rawRelative.startsWith(`..${path.sep}`) ||
+ 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 +60,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 +69,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/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 384fdc7f..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,20 +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/prefer-string-raw': '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/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/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 3bc53c6e..33876fe7 100644
--- a/modules/presentation/components/design-preview.tsx
+++ b/modules/presentation/components/design-preview.tsx
@@ -87,8 +87,8 @@ function loadImage(src: string): Promise {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
- img.onload = () => resolve(img);
- img.onerror = () => resolve(null);
+ img.addEventListener('load', () => resolve(img));
+ img.addEventListener('error', () => resolve(null));
img.src = src;
});
}
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/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/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/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/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*',
diff --git a/scripts/optimize-sprites.mjs b/scripts/optimize-sprites.mjs
index 17ba409c..f401562a 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;
@@ -69,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