KwadMarket Docs
Remediation Plan

Data Access

PrismaService DI, remaining migrations, typed queries — plan phase 5

Feeds plan phase 5 (the pagination clamp in §6 lands earlier, with phase 4). The migrations baseline is done (3 migrations committed, db:push out of deploy paths) — §2 below only lists what's left.

1. Injected PrismaService (replaces the global singleton)

Every service does import { prisma } from '@marketplace/database'; discussions.controller.ts and jwt.strategy.ts import it too. Consequences: nothing is unit-testable without a live DB, no connection lifecycle, controllers can bypass services.

The scraper app already has the correct pattern (apps/scraper/src/prisma/prisma.service.ts). Mirror it:

apps/back/src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@marketplace/database';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() { await this.$connect(); }
  async onModuleDestroy() { await this.$disconnect(); }
}
apps/back/src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';

@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
export class PrismaModule {}

Register PrismaModule in app.module.ts. Then per service:

// before
import { prisma } from '@marketplace/database';
async findById(id: number) { return prisma.deal.findUnique(...) }

// after
constructor(private readonly prisma: PrismaService, ...) {}
async findById(id: number) { return this.prisma.deal.findUnique(...) }

Mechanical, one module per commit. @marketplace/database keeps exporting the PrismaClient class and types; the prisma singleton export stays only for apps/scraper (or gets the same treatment there) and the seed script. Grep gate when done: grep -rn "import { prisma }" apps/back/src → 0.

2. Migrations — remaining items (baseline is done)

One follow-up migration covering:

  • Missing FK indexes (Postgres does not auto-index FKs): Deal[status,createdAt], Deal[userId], Product[categoryId,status], Product[brandId], Shop[productId], Discussion[buyerId]/[sellerId]/[dealId], ScrapedProduct[scrapingStatus], ScrapedProduct[scraperSourceId] — a shop-url index already landed separately.
  • Shop.price / ScrapedProduct.price FloatDecimal (schema.prisma:245,444; Deal.price is already Decimal). Price comparison is the core product feature — don't compute it on floats.
  • Drop RememberMeToken (schema.prisma:94, unreferenced leftover) and drop RateLimit (:124, once the throttler from Security §6 lands).
  • Spec.specTypeId is nullable — if an orphan spec is meaningless, make it required.

CI: add prisma migrate diff --from-migrations ./migrations --to-schema-datamodel ./schema.prisma --exit-code to catch schema/migration drift.

3. Typed queries + mappers (the Promise<any> killer)

Pattern — define the include once, derive the type, map at the boundary:

deals/deals.types.ts
import { Prisma } from '@marketplace/database';

export const dealWithRelations = Prisma.validator<Prisma.DealDefaultArgs>()({
  include: {
    products: { include: { product: { include: { category: true, brand: true } } } },
    user: { select: { id: true, name: true, image: true } },
  },
});
export type DealWithRelations = Prisma.DealGetPayload<typeof dealWithRelations>;
deals.service.ts
async findById(id: number): Promise<DealWithRelations | null> {
  return this.prisma.deal.findUnique({ where: { id }, ...dealWithRelations });
}
deals.mapper.ts — now typed end to end
import type { Deal } from '@marketplace/types';
export function mapDeal(deal: DealWithRelations): Deal { ... }

Apply per module (deals → products → discussions → users → the rest). The controller signatures already declare Promise<Deal> from @marketplace/types; once services are typed, tsc will verify mapper correctness for free.

Mappers everywhere: discussions.controller.ts repeats its output-mapping object 3×; create discussions.mapper.ts (mapDiscussion(d, currentUserId) — it owns the hasUnread derivation). Same for any controller building response literals inline.

4. Transactions + batch writes

deals.service.ts update() (lines ~247–260) does deleteMany then a sequential create loop — non-atomic (a failure mid-loop loses the deal's products) and N+1:

// after
if (products !== undefined) {
  await this.prisma.$transaction([
    this.prisma.dealProduct.deleteMany({ where: { dealId: id } }),
    this.prisma.dealProduct.createMany({
      data: products.map((p) => ({ dealId: id, productId: p.productId, quantity: p.quantity })),
    }),
  ]);
}

Same review for ProductsService.updateSpecs (delete+recreate spec links) and DiscussionsService.create (discussion + two statuses should be one $transaction, or statuses: { createMany: ... } nested write). Rule: any multi-write that must be all-or-nothing goes through $transaction (interactive transactions only when a read decides a write).

5. Delete the undefined-spread boilerplate

deals.service.ts update() builds ...(dealData.title !== undefined && { title: dealData.title }) for nine fields. Prisma ignores undefined values natively — the entire block collapses to:

const { products, condition, ...dealData } = data;
await this.prisma.deal.update({
  where: { id },
  data: { ...dealData, condition }, // condition typed as DealCondition via the DTO — no `as any`
});

Grep for the pattern elsewhere: grep -rn "!== undefined && {" apps/back/src.

6. Shared pagination helper

Every list service re-implements skip/take + meta math. Centralize:

common/pagination.ts
export interface PaginationMeta {
  total: number; page: number; limit: number; totalPages: number;
}
export interface Paginated<T> { data: T[]; meta: PaginationMeta }

export const MAX_PAGE_SIZE = 50;

export function pageArgs(page = 1, limit = 20) {
  const take = Math.min(Math.max(1, limit), MAX_PAGE_SIZE);   // clamps /deals?limit=100000
  const safePage = Math.max(1, page);
  return { skip: (safePage - 1) * take, take, page: safePage, limit: take };
}

export function buildMeta(total: number, page: number, limit: number): PaginationMeta {
  return { total, page, limit, totalPages: Math.max(1, Math.ceil(total / limit)) };
}

Usage in any service:

const { skip, take, page: p, limit: l } = pageArgs(page, limit);
const [data, total] = await this.prisma.$transaction([
  this.prisma.deal.findMany({ where, skip, take, ...dealWithRelations, orderBy: { createdAt: 'desc' } }),
  this.prisma.deal.count({ where }),
]);
return { data, meta: buildMeta(total, p, l) };

This also fixes the unbounded-limit issue flagged in plan phase 4 in one place. Reconcile with the existing common/types/pagination.types.ts (extend it; don't create a second meta shape).

On this page