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:
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',
}));constructor(@Inject(authConfig.KEY) private readonly auth: ConfigType<typeof authConfig>) {}
// this.auth.jwtSecret — typed, no fallback logic at call sitesRegister 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/swaggerif (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@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
handleConnectionqueries all of a user's discussions and joins every room on connect. Cheaper and simpler: join onlyuser:{id}on connect; joindiscussion:{id}lazily via the existingjoinDiscussionmessage (the client already sends it). Recipients who don't have the chat open are reached via theiruser:{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-adapterusing the existing Redis. Leave a comment in the gateway noting this.
6. Observability & health
pnpm --filter back add @nestjs/terminus @sentry/nodemodules/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.tswhenSENTRY_DSNis set; add a global exception filter that captures non-HttpExceptionerrors (and 5xx only). Same forapps/scraper— failed scrape jobs should be visible somewhere other than Redis. - Once both exist, wire the Docker/nginx deploy to
/api/health(alignDEPLOYMENT.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
ProductsServiceand the scraper import path;reindexAllexists as the recovery hatch. - Meilisearch must run with
MEILISEARCH_API_KEYset in production (env.config.tsshouldrequireit in production likeJWT_SECRET). - Deals search stays Postgres-
ILIKEfor now (fine at current volume). When deal volume justifies it, index deals in Meilisearch through the sameSearchServicerather than a second client.
8. apps/back ⇄ apps/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/typesso producer and consumer can't drift. apps/scraperkeeps its ownPrismaService; it must never import fromapps/back/src(and vice versa). Add an ESLintno-restricted-importsrule 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@2drags criticalhandlebars/liquidjs+ highnodemailer/lodash/html-minifier. Bump major or consolidate email sending onresend(already a direct dep) and drop the module.- Bump
multer≥2.2 (direct, high) andnext≥16.2.5 (web, high). - Delete unused GraphQL-era deps:
@as-integrations/express5,@nestjs/microservices— zero imports insrc/. jsonwebtokenis used directly (auth.service.ts) alongsidepassport-jwt— migrate to@nestjs/jwtwhen 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.ymlfiles plus root redis/meili composes — consolidate into one rootdocker-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.emailVerifiedonly set for OAuth) — gate deal publishing on it. - Deal expiry:
DealStatus.EXPIREDexists 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 fromapps/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):
generateMetadataOG 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_KEYrequired in production (§7);dump/dump.sqlremoved 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-inapp.enableVersioning()then. Node version:— fixed: rootenginessays>=18but CI runs 20"engines"pins>=20and.nvmrchas20.- Keep
@marketplace/typesas 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.