KwadMarket Docs
Remediation Plan

Backend Quality

Types, DTOs, thin controllers, error policy, logging, decomposition — plan phase 6

Feeds plan phase 6 (§1 lands earlier, with phase 3). Measured 2026-07-02: 70 Promise<any>, 8 as any, user: any in every controller, 19 console.*, throw new Error in services, giants unchanged (products.service.ts 822 lines / scraper.service.ts 793). The rules these specs implement are summarized in the backend conventions.

1. RequestUser — one type for @CurrentUser()

Today every handler writes @CurrentUser() user: any. Define once:

common/types/request-user.ts
import type { UserRole } from '@marketplace/types';

export interface RequestUser {
  id: string;
  email: string;
  name: string | null;
  image: string | null;
  role: UserRole;
}

This is exactly what JwtStrategy.validate returns after the fix in Security §2. Optionally type the decorator itself:

common/decorators/current-user.decorator.ts
export const CurrentUser = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): RequestUser =>
    ctx.switchToHttp().getRequest().user,
);

Grep gate: grep -rn "@CurrentUser() user: any" apps/back/src → 0.

2. DTO conventions

DTO coverage is good; tighten the conventions:

  • Enums in DTOs, not string. UpdateDealDto.condition should be @IsEnum(DealCondition) condition?: DealCondition so condition as any in the service disappears. Same for AdminUpdateStatusDto.status (DealStatus) and the scraper setStatus (ScrapedProductStatus). The enums come from @marketplace/types / Prisma.
  • Query params get DTOs too. Controllers currently take long lists of @Query('x') x?: number. For deals.search / products.search, define a SearchDealsQueryDto with @Type(() => Number), @IsOptional(), and a @Transform for the comma-separated categoryIds/specIds:
export class SearchDealsQueryDto {
  @IsOptional() @IsString() title?: string;
  @IsOptional() @Transform(({ value }) => String(value).split(',').map(Number))
  @IsInt({ each: true }) categoryIds?: number[];
  @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
  @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(50) limit?: number;
}

Handler shrinks to async search(@Query() query: SearchDealsQueryDto) and the .split(',').map(Number) parsing leaves the controller.

  • Password policy: RegisterDto/ResetPasswordDto should enforce @MinLength(8) (verify current rules; add if missing).

3. Thin controllers

Rule: a controller method is ≤ ~10 lines — guard decorators, DTO in, service call, mapper out. Current violations to fix:

  • discussions.controller.ts startDiscussion (≈45 lines, imports prisma directly): move find-or-create + deal validation into DiscussionsService.startDiscussion(dealId, buyer); controller becomes 3 lines. Combined with the participant checks from Security §4.
  • Any controller doing .split(',') parsing (replaced by query DTOs above).

4. Error policy

  • Services throw NotFoundException / ForbiddenException / BadRequestException / ConflictException. Grep gate: grep -rn "throw new Error(" apps/back/src → 0 (currently: discussions controller, messages controller, upload uploadFromUrl).
  • findById-style endpoints must 404, not return null with 200: deals.controller.ts findById currently returns Deal | null — change to throw NotFoundException when missing (small API change; frontend getDealById already treats errors as not-found via notFound() — verify and align).
  • Don't catch-and-swallow: auth.service.ts requestPasswordReset wraps everything in try/catch returning {success:true} — keep the response constant (anti-enumeration is correct) but let unexpected DB errors propagate to the exception filter after logging; only the mailer failure is intentionally swallowed.
  • Add one global exception filter only if/when Sentry lands (Operations §6) — Nest's default is fine until then.

5. Logging

Replace all 19 console.* with Nest Logger:

export class AuthService {
  private readonly logger = new Logger(AuthService.name);
  // console.log(`✓ Password reset email sent to: ${email}`)
  // →
  this.logger.log(`Password reset email sent (userId=${user.id})`);
}

Conventions: log ids, not emails (PII in prod logs); logger.error(message, error.stack) for failures; no emoji. Grep gate: grep -rn "console\." apps/back/src → 0 (except main.ts bootstrap line, which becomes Logger.log too).

6. Decompose the two giant service methods

ProductsService.importFromJson (230 lines). Split into pure, individually-testable private helpers — same class, no behavior change:

async importFromJson(json: string): Promise<ImportResult> {
  const items = this.parseImportPayload(json);          // JSON.parse + shape validation → typed ImportItem[]
  const ctx = await this.loadImportContext();           // categories/brands/specs lookups, once
  const result = emptyImportResult();
  for (const item of items) {
    try {
      await this.importOne(item, ctx, result);          // upsert product + specs + shops
    } catch (e) {
      result.failed++;
      result.errors.push(`${item.name}: ${(e as Error).message}`);
    }
  }
  return result;
}

ProductsService.search (158 lines). Extract the where-builder and the sort logic as pure functions in products.query-builder.ts:

export function buildProductWhere(params: ProductSearchParams): Prisma.ProductWhereInput { ... }
export function buildProductOrderBy(sortBy?: string, sortOrder?: string): Prisma.ProductOrderByWithRelationInput { ... }

search() becomes: build where → pageArgs$transaction([findMany, count])buildMeta. Both pure functions get unit tests (Testing §4). Apply the same split to DealsService.search (77 lines).

ScraperService (back) at 40 symbols / ~600 lines is doing four jobs: shops/sources CRUD, spec-mapping CRUD, scraped-product lifecycle, queue actions. Split into scraper-admin.service.ts (CRUD), scraper-import.service.ts (importToCatalog/syncSpecs/syncPrice), scraper-queue.service.ts (runSource/scrapeAllReady/job status) — the controller already groups endpoints this way (its section comments mark the seams).

7. Misc rules

  • No raw SQL unless necessary: the one $queryRaw (spec-mapping aggregation in scraper.service.ts) is justified (JSON aggregation) — keep, but add a comment stating why Prisma can't express it.
  • Promise<any> removal is part of touching a file, not a separate project: whenever a step edits a service, its public methods get real return types (pattern in Data access §3).
  • Naming: services use findX/createX/updateX/deleteX; no getX mixed in (currently getCatalogStatus, getPaginatedByStatus — rename opportunistically, not as a dedicated PR).
  • Module boundary: only scraper-import.service.ts may write to catalog tables (Product, Spec, Shop) from the scraper module; everything else goes through ProductsService. Document at top of the module.

On this page