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:
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:
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.conditionshould be@IsEnum(DealCondition) condition?: DealConditionsocondition as anyin the service disappears. Same forAdminUpdateStatusDto.status(DealStatus) and the scrapersetStatus(ScrapedProductStatus). The enums come from@marketplace/types/ Prisma. - Query params get DTOs too. Controllers currently take long lists of
@Query('x') x?: number. Fordeals.search/products.search, define aSearchDealsQueryDtowith@Type(() => Number),@IsOptional(), and a@Transformfor the comma-separatedcategoryIds/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/ResetPasswordDtoshould 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, importsprismadirectly): move find-or-create + deal validation intoDiscussionsService.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, uploaduploadFromUrl). findById-style endpoints must 404, not returnnullwith 200:deals.controller.ts findByIdcurrently returnsDeal | null— change to throwNotFoundExceptionwhen missing (small API change; frontendgetDealByIdalready treats errors as not-found vianotFound()— verify and align).- Don't catch-and-swallow:
auth.service.ts requestPasswordResetwraps 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 inscraper.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; nogetXmixed in (currentlygetCatalogStatus,getPaginatedByStatus— rename opportunistically, not as a dedicated PR). - Module boundary: only
scraper-import.service.tsmay write to catalog tables (Product,Spec,Shop) from the scraper module; everything else goes throughProductsService. Document at top of the module.