KwadMarket Docs
Remediation Plan

Operations

Config, Swagger, jobs, notifications seam, observability, dependencies, launch checklist — plan phases 7 and 9

Feeds plan phases 7 and 9. Nothing here is implemented yet except the gateway's per-user unread pushes (§5), which the frontend now relies on.

1. Typed configuration namespaces

ConfigService.get<string>('X') || 'fallback' is scattered through services (auth, gateway, upload, mailer), each with its own fallback. Replace with registerAs namespaces — defaults live in exactly one place, consumers get typed objects:

config/app.config.ts
import { registerAs } from '@nestjs/config';

export const authConfig = registerAs('auth', () => ({
  jwtSecret: process.env.JWT_SECRET!,                 // presence guaranteed by Joi (Security §1)
  jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
}));

export const s3Config = registerAs('s3', () => ({
  endpoint: process.env.S3_ENDPOINT,
  bucket: process.env.S3_BUCKET ?? process.env.AWS_S3_BUCKET ?? 'marketplace-ts-uploads',
  baseUrl: process.env.S3_BASE_URL,
  region: process.env.AWS_REGION ?? 'us-east-1',
}));
consumer
constructor(@Inject(authConfig.KEY) private readonly auth: ConfigType<typeof authConfig>) {}
// this.auth.jwtSecret — typed, no fallback logic at call sites

Register via ConfigModule.forRoot({ load: [authConfig, s3Config, ...] }). Migrate opportunistically per touched module; don't do a dedicated sweep.

S3_ENDPOINT/S3_BUCKET/S3_BASE_URL are read by upload.service.ts but missing from env.config.ts Joi schema — add them while here.

2. OpenAPI / Swagger

The frontend hand-maintains response types in @marketplace/types; drift is invisible until runtime. Add Swagger now (cheap), consider client generation later:

pnpm --filter back add @nestjs/swagger
main.ts — dev/staging only
if (process.env.NODE_ENV !== 'production') {
  const config = new DocumentBuilder()
    .setTitle('KwadMarket API').setVersion('1.0').addBearerAuth().build();
  SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, config));
}

DTOs are already classes — they document themselves. Add @ApiProperty only where inference fails. Payoff: implementer agents and the frontend get a browsable, always-current contract at /api/docs. Future-proofing option (not now): generate the frontend client from the OpenAPI JSON (openapi-typescript) replacing hand-written features/*/api.ts types.

3. Scheduled jobs module

Several production needs are cron-shaped (deal expiry, orphaned-S3 cleanup, password-token purge). One pattern for all of them:

pnpm --filter back add @nestjs/schedule
modules/jobs/deal-expiry.job.ts — registered in AppModule via ScheduleModule.forRoot()
@Injectable()
export class DealExpiryJob {
  private readonly logger = new Logger(DealExpiryJob.name);
  constructor(private readonly prisma: PrismaService) {}

  @Cron(CronExpression.EVERY_DAY_AT_3AM)
  async expireOldDeals() {
    const cutoff = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); // 60 days
    const { count } = await this.prisma.deal.updateMany({
      where: { status: DealStatus.PUBLISHED, updatedAt: { lt: cutoff } },
      data: { status: DealStatus.EXPIRED },
    });
    if (count > 0) this.logger.log(`Expired ${count} deals`);
  }
}

Same module hosts: @Cron purge of expired PasswordResetToken rows; later the S3 orphan sweep. If the deployment ever runs >1 API replica, move these to BullMQ repeatable jobs (Redis is present) so they don't run N times — note this constraint in the module.

4. Notifications: design the seam now, implement incrementally

Multiple roadmap features (deal approved/declined emails, new-message email, price alerts) need the same machinery. Don't bolt mailer calls into deals/messages services; use Nest's event bus as the seam:

pnpm --filter back add @nestjs/event-emitter
// Domain services emit facts:
this.eventEmitter.emit('deal.status.changed', new DealStatusChangedEvent(deal.id, deal.userId, status, reason));

// modules/notifications/notifications.listener.ts decides what to do with them:
@OnEvent('deal.status.changed')
async onDealStatusChanged(e: DealStatusChangedEvent) {
  if (e.status === DealStatus.DECLINED) await this.mailer.sendDealDeclined(...);
  if (e.status === DealStatus.PUBLISHED) await this.mailer.sendDealPublished(...);
}

First implementation: emit events from DealsService.updateStatus and MessagesService (message created); one listener module owning all mailer templates. A Notification DB table (in-app notification center) plugs into the same listener later without touching domain services. Keep it in-process — no Kafka/queue ceremony at this scale; BullMQ only if a notification action becomes slow (bulk emails).

5. WebSocket scaling & behavior

  • handleConnection queries all of a user's discussions and joins every room on connect. Cheaper and simpler: join only user:{id} on connect; join discussion:{id} lazily via the existing joinDiscussion message (the client already sends it). Recipients who don't have the chat open are reached via their user:{id} room — which is also what the unread-badge push (already consumed by the web app) needs.
  • When (and only when) running >1 API replica: add @socket.io/redis-adapter using the existing Redis. Leave a comment in the gateway noting this.

6. Observability & health

pnpm --filter back add @nestjs/terminus @sentry/node
  • modules/health/health.controller.ts: @Public() @Get('health') checking Prisma (SELECT 1), Redis ping, Meilisearch health. Used by Docker healthcheck/uptime monitor. Keep it out of the throttler.
  • Sentry: initialize in main.ts when SENTRY_DSN is set; add a global exception filter that captures non-HttpException errors (and 5xx only). Same for apps/scraper — failed scrape jobs should be visible somewhere other than Redis.
  • Once both exist, wire the Docker/nginx deploy to /api/health (align DEPLOYMENT.md).

See also the observability best-practice page for the full Sentry wiring pattern (Next.js + NestJS + scraper).

7. Search consistency

SearchService (Meilisearch) indexes products; reindex calls are sprinkled through ProductsService mutations. Rules to enforce:

  • Every product mutation path updates the index (create/update/delete/updateSpecs/import) — audit ProductsService and the scraper import path; reindexAll exists as the recovery hatch.
  • Meilisearch must run with MEILISEARCH_API_KEY set in production (env.config.ts should require it in production like JWT_SECRET).
  • Deals search stays Postgres-ILIKE for now (fine at current volume). When deal volume justifies it, index deals in Meilisearch through the same SearchService rather than a second client.

8. apps/backapps/scraper boundary (document, don't re-architect)

Current split is right: back owns the catalog and enqueues; scraper consumes BullMQ jobs, talks to BrightData, writes only Scraper*/ScrapedProduct tables. Two guardrails:

  • The job payload types (PlpJobData/PdpJobData) are declared in both apps independently — move them to @marketplace/types so producer and consumer can't drift.
  • apps/scraper keeps its own PrismaService; it must never import from apps/back/src (and vice versa). Add an ESLint no-restricted-imports rule for both paths.

9. Dependencies (phase 7 — do first within this doc)

Measured 2026-07-02 (pnpm audit --prod): 2 critical + 39 high.

  • @nestjs-modules/mailer@2 drags critical handlebars/liquidjs + high nodemailer/lodash/html-minifier. Bump major or consolidate email sending on resend (already a direct dep) and drop the module.
  • Bump multer ≥2.2 (direct, high) and next ≥16.2.5 (web, high).
  • Delete unused GraphQL-era deps: @as-integrations/express5, @nestjs/microservices — zero imports in src/.
  • jsonwebtoken is used directly (auth.service.ts) alongside passport-jwt — migrate to @nestjs/jwt when touching auth.
  • Add Renovate or Dependabot; add pnpm audit --prod (non-blocking at first) to CI. There is no update automation today.
  • Docker hygiene: four per-app docker-compose.yml files plus root redis/meili composes — consolidate into one root docker-compose.dev.yml (postgres + redis + meili) and one production stack file.

10. Launch checklist (phase 9)

Functional/operational gaps — not bugs, not refactors.

User-visible:

  • Email verification for local accounts (User.emailVerified only set for OAuth) — gate deal publishing on it.
  • Deal expiry: DealStatus.EXPIRED exists but nothing sets it → the §3 cron.
  • Account deletion / GDPR (soft-delete + anonymize messages/deals) and password change for logged-in users (only reset-by-email exists).
  • Notification emails for marketplace events (deal approved/declined, new message when offline) → §4 seam; verify the seller actually sees reasonDeclined.
  • Image lifecycle: orphaned-S3-object cleanup job; thumbnails/size validation; re-upload scraped images to S3 at import, then remove the hostname: "**" pattern from apps/web/next.config.ts (the image optimizer is currently an open proxy for any HTTPS URL).

Operational:

  • Backups (automated pg_dump; Meili re-index from DB as recovery path); uptime monitoring against /api/health (§6); structured logging with request ids (after Logger migration).
  • SEO basics (organic traffic is the business): generateMetadata OG tags on deal/product pages, sitemap.xml, robots.txt, canonicals.
  • Legal pages (ToS, privacy, cookies — EU requirement; OAuth providers ask for the privacy URL). See the legal & compliance spec. Scraping posture: respect robots.txt, identify the bot, be ready for takedowns.
  • MEILISEARCH_API_KEY required in production (§7); dump/dump.sql removed from git (see Security §8).

11. Versioning & deprecation posture (future-proofing, zero code today)

  • Don't add URL versioning (/v1) now — single first-party client. The cheap insurance instead: never change a response shape destructively; add fields, deprecate later. If a public/mobile API ever ships, enable Nest's built-in app.enableVersioning() then.
  • Node version: engines says >=18 but CI runs 20 — fixed: root "engines" pins >=20 and .nvmrc has 20.
  • Keep @marketplace/types as the single contract package; backend imports enums from it (or re-exports Prisma enums through it) so frontend/back never hand-duplicate an enum again.

On this page