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:
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(); }
}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.priceFloat→Decimal(schema.prisma:245,444;Deal.priceis alreadyDecimal). Price comparison is the core product feature — don't compute it on floats.- Drop
RememberMeToken(schema.prisma:94, unreferenced leftover) and dropRateLimit(:124, once the throttler from Security §6 lands). Spec.specTypeIdis 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:
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>;async findById(id: number): Promise<DealWithRelations | null> {
return this.prisma.deal.findUnique({ where: { id }, ...dealWithRelations });
}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:
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).