Basic Architecture of Sport Web

This commit is contained in:
Nông Đức Huy
2026-08-11 13:37:25 +07:00
commit 8032fff6ac
262 changed files with 20348 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# Module anatomy
Every feature module follows the same internal shape. Consistency here is worth
more than local cleverness — a developer opening `orders/` for the first time
should already know where everything is.
```
<module>/
├── <module>.module.ts # Wiring only. No logic, ever.
├── <module>.controller.ts # HTTP surface: parse, delegate, return. No rules.
├── <module>.service.ts # Business rules. The only interesting file.
├── <module>.repository.ts # The ONLY file allowed to touch PrismaService.
├── dto/ # Request/response shapes + Zod schema bindings.
├── mappers/ # Prisma row → API type. Keeps Prisma types internal.
├── events/ # Events this module publishes and subscribes to.
└── public/
└── index.ts # The only entry point for other modules.
```
## The four rules
1. **Controllers contain no business logic.** If a controller has an `if` that
is not input shaping, the rule belongs in the service.
2. **Only the repository imports Prisma.** Services depend on repository
interfaces. This is what makes services unit-testable without a database and
what keeps a later storage change from rippling outward.
3. **A module owns its tables exclusively.** `OrdersModule` never queries
`products` — it asks `ProductsModule`'s public service, or it stores a
snapshot. Shared tables are how a monolith becomes unsplittable.
4. **Cross-module imports go through `public/`.** Deep imports are blocked by
ESLint (`@sport/eslint-config/nest`). If you need something that is not
exported, widen the public surface deliberately — do not reach around it.
## Talking to another module
| Need | Mechanism |
| ---------------------------------------- | ------------------------------------------------ |
| An answer, now, to continue this request | Call its public service |
| To react to something that happened | Subscribe to its domain event |
| To change its data | Call its public service — never write its tables |
## Modules marked EXTRACTION CANDIDATE
`inventory`, `orders`, `payments` and `search` are written so they could become
independent services later: no foreign reads, communication via events, and no
shared transactions with the rest of the monolith beyond their own tables.
That is a _constraint on how they are written_, not a plan to extract them.
Extraction is justified by a real scaling or team-boundary problem, and nothing
here assumes it will ever happen.
+28
View File
@@ -0,0 +1,28 @@
import { Global, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { AccessTokenGuard } from './guards/access-token.guard';
import { PermissionsGuard } from './guards/permissions.guard';
/**
* Milestone 0 provides the *enforcement* half of auth: token verification,
* audience separation and RBAC evaluation, wired globally.
*
* The *issuance* half — login, registration, refresh rotation, password reset,
* OTP — is milestone 1. Splitting it this way means every endpoint written from
* here on is protected by default, before a single credential exists.
*
* Guard order matters: AccessTokenGuard must populate `request.actor` before
* PermissionsGuard reads it. Nest runs APP_GUARD providers in registration order.
*/
@Global()
@Module({
imports: [JwtModule.register({})],
providers: [
{ provide: APP_GUARD, useClass: AccessTokenGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
exports: [JwtModule],
})
export class AuthModule {}
@@ -0,0 +1,96 @@
import { Inject, Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
import {
API_ERROR_CODES,
type AccessTokenClaims,
type AuthenticatedActor,
type TokenAudience,
} from '@sport/types';
import { METADATA_KEYS } from '@/common/constants/api';
import { AppException } from '@/common/errors/app.exception';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
/**
* Registered globally: authentication is opt-OUT via `@Public()`, never opt-in.
* A new controller written by someone who forgets to think about auth is
* protected by default. That asymmetry is the whole design.
*
* The guard is stateless — no database read on the hot path. Permissions travel
* inside the access token, which is why access tokens are short-lived: a
* revoked permission takes at most one token lifetime to take effect.
*/
@Injectable()
export class AccessTokenGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(METADATA_KEYS.IS_PUBLIC, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const token = extractBearerToken(request);
if (!token) {
throw AppException.unauthenticated();
}
let claims: AccessTokenClaims;
try {
claims = await this.jwtService.verifyAsync<AccessTokenClaims>(token, {
secret: this.config.auth.accessSecret,
issuer: this.config.auth.issuer,
});
} catch (error) {
const expired = error instanceof Error && error.name === 'TokenExpiredError';
throw AppException.unauthenticated(
expired ? 'Your session has expired. Please sign in again.' : 'Invalid credentials.',
expired ? API_ERROR_CODES.TOKEN_EXPIRED : API_ERROR_CODES.TOKEN_INVALID,
);
}
// Audience check runs before any permission logic: a storefront token must
// never reach an admin endpoint even if it somehow carried the permission.
const requiredAudience = this.reflector.getAllAndOverride<TokenAudience>(
METADATA_KEYS.TOKEN_AUDIENCE,
[context.getHandler(), context.getClass()],
);
if (requiredAudience && claims.aud !== requiredAudience) {
throw AppException.forbidden('This credential cannot be used here.');
}
const actor: AuthenticatedActor = {
userId: claims.sub,
userType: claims.type,
audience: claims.aud,
permissions: claims.permissions ?? [],
sessionId: claims.sid,
};
request.actor = actor;
return true;
}
}
function extractBearerToken(request: Request): string | null {
const header = request.header('authorization');
if (!header) return null;
const [scheme, value] = header.split(' ');
return scheme?.toLowerCase() === 'bearer' && value ? value : null;
}
@@ -0,0 +1,82 @@
import { Reflector } from '@nestjs/core';
import { PERMISSIONS, USER_TYPES, TOKEN_AUDIENCES, type AuthenticatedActor } from '@sport/types';
import { METADATA_KEYS } from '@/common/constants/api';
import { AppException } from '@/common/errors/app.exception';
import { PermissionsGuard } from './permissions.guard';
/**
* These tests exist because authorization is the one thing that must never
* regress quietly. They pin the three behaviours the rest of the codebase
* relies on: default-allow only when nothing is required, all-of semantics,
* and any-of semantics.
*/
function makeContext(actor: AuthenticatedActor | undefined) {
return {
switchToHttp: () => ({ getRequest: () => ({ actor }) }),
getHandler: () => () => undefined,
getClass: () => class {},
} as never;
}
function makeActor(permissions: AuthenticatedActor['permissions']): AuthenticatedActor {
return {
userId: 'user-1',
userType: USER_TYPES.STAFF,
audience: TOKEN_AUDIENCES.ADMIN,
permissions,
sessionId: 'session-1',
};
}
function makeReflector(required?: string[], mode?: 'all' | 'any') {
const reflector = new Reflector();
jest
.spyOn(reflector, 'getAllAndOverride')
.mockImplementation((key: unknown) =>
key === METADATA_KEYS.REQUIRED_PERMISSIONS ? required : mode,
);
return reflector;
}
describe('PermissionsGuard', () => {
it('allows a route that declares no permissions', () => {
const guard = new PermissionsGuard(makeReflector(undefined));
expect(guard.canActivate(makeContext(makeActor([])))).toBe(true);
});
it('allows when the actor holds every required permission', () => {
const guard = new PermissionsGuard(
makeReflector([PERMISSIONS.PRODUCT_READ, PERMISSIONS.PRODUCT_UPDATE], 'all'),
);
const actor = makeActor([PERMISSIONS.PRODUCT_READ, PERMISSIONS.PRODUCT_UPDATE]);
expect(guard.canActivate(makeContext(actor))).toBe(true);
});
it('denies when one of several required permissions is missing', () => {
const guard = new PermissionsGuard(
makeReflector([PERMISSIONS.PRODUCT_READ, PERMISSIONS.PRODUCT_DELETE], 'all'),
);
const actor = makeActor([PERMISSIONS.PRODUCT_READ]);
expect(() => guard.canActivate(makeContext(actor))).toThrow(AppException);
});
it('allows under "any" mode when at least one permission matches', () => {
const guard = new PermissionsGuard(
makeReflector([PERMISSIONS.ORDER_READ, PERMISSIONS.ORDER_REFUND], 'any'),
);
const actor = makeActor([PERMISSIONS.ORDER_READ]);
expect(guard.canActivate(makeContext(actor))).toBe(true);
});
it('denies an unauthenticated request on a permissioned route', () => {
const guard = new PermissionsGuard(makeReflector([PERMISSIONS.ORDER_READ], 'all'));
expect(() => guard.canActivate(makeContext(undefined))).toThrow(AppException);
});
});
@@ -0,0 +1,55 @@
import { Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { hasAllPermissions, hasAnyPermission, type Permission } from '@sport/types';
import { METADATA_KEYS } from '@/common/constants/api';
import { AppException } from '@/common/errors/app.exception';
/**
* Enforces `@RequirePermissions(...)`. Runs after AccessTokenGuard, so the
* actor is guaranteed present on any non-public route.
*
* Routes with no permission metadata pass: authentication alone is enough for
* "any signed-in customer" endpoints such as /me. Anything touching business
* data must declare its permissions explicitly.
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Permission[]>(
METADATA_KEYS.REQUIRED_PERMISSIONS,
[context.getHandler(), context.getClass()],
);
if (!required || required.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const actor = request.actor;
if (!actor) {
throw AppException.unauthenticated();
}
const mode = this.reflector.getAllAndOverride<'all' | 'any'>(METADATA_KEYS.PERMISSION_MODE, [
context.getHandler(),
context.getClass(),
]);
const granted =
mode === 'any'
? hasAnyPermission(actor.permissions, required)
: hasAllPermissions(actor.permissions, required);
if (!granted) {
throw AppException.forbidden();
}
return true;
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* BrandsModule — boundary declared, implementation pending.
*
* Owns (exclusively): `brands`
*
* Deliberately thin. Kept separate anyway because brand pages, filters and (later) brand-level commercial terms all hang off it.
*
* Anatomy once implemented (see ../README.md):
* brands.module.ts wiring only
* brands.controller.ts HTTP surface, no logic
* brands.service.ts business rules
* brands.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class BrandsModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of BrandsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* CartsModule — boundary declared, implementation pending.
*
* Owns (exclusively): Redis (guest carts) + `carts`/`cart_items` once persisted — milestone 2
*
* Guest carts live in Redis keyed by an anonymous token; they are promoted to PostgreSQL on sign-in. Cart totals are always recomputed server-side from current variant prices — a client-submitted price is never trusted.
*
* Anatomy once implemented (see ../README.md):
* carts.module.ts wiring only
* carts.controller.ts HTTP surface, no logic
* carts.service.ts business rules
* carts.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class CartsModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of CartsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* CategoriesModule — boundary declared, implementation pending.
*
* Owns (exclusively): `categories`
*
* The hierarchical merchandising tree and the navigation menu it feeds. Heavy read, near-zero write — the first thing that should be Redis-cached.
*
* Anatomy once implemented (see ../README.md):
* categories.module.ts wiring only
* categories.controller.ts HTTP surface, no logic
* categories.service.ts business rules
* categories.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class CategoriesModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of CategoriesModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* CheckoutModule — boundary declared, implementation pending.
*
* Owns (exclusively): Checkout sessions (Redis, short TTL)
*
* Orchestrates the cart → stock reservation → payment intent → order transition. The only module allowed to coordinate across contexts, and it does so through public services and events.
*
* Anatomy once implemented (see ../README.md):
* checkout.module.ts wiring only
* checkout.controller.ts HTTP surface, no logic
* checkout.service.ts business rules
* checkout.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class CheckoutModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of CheckoutModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
+19
View File
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* CmsModule — boundary declared, implementation pending.
*
* Owns (exclusively): `pages`, `blog_posts`, `banners`, `navigation_menus` — milestone 3
*
* Homepage blocks, /blog and static pages. Editorial content is versioned and previewable; it never becomes a general-purpose page builder.
*
* Anatomy once implemented (see ../README.md):
* cms.module.ts wiring only
* cms.controller.ts HTTP surface, no logic
* cms.service.ts business rules
* cms.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class CmsModule {}
+10
View File
@@ -0,0 +1,10 @@
/**
* Public surface of CmsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* CollectionsModule — boundary declared, implementation pending.
*
* Owns (exclusively): `collections`, `product_collections`
*
* Editorial and campaign groupings, including rule evaluation for AUTOMATED collections.
*
* Anatomy once implemented (see ../README.md):
* collections.module.ts wiring only
* collections.controller.ts HTTP surface, no logic
* collections.service.ts business rules
* collections.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class CollectionsModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of CollectionsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* CouponsModule — boundary declared, implementation pending.
*
* Owns (exclusively): `coupons`, `coupon_redemptions` — milestone 3
*
* Code-driven discounts. Redemption counting must be transactional; a race here gives away unlimited free money.
*
* Anatomy once implemented (see ../README.md):
* coupons.module.ts wiring only
* coupons.controller.ts HTTP surface, no logic
* coupons.service.ts business rules
* coupons.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class CouponsModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of CouponsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* CustomersModule — boundary declared, implementation pending.
*
* Owns (exclusively): `customers`, `addresses`
*
* Shopper profiles and address book. Separate from `users` so customer PII can later live under a stricter access policy without touching staff accounts.
*
* Anatomy once implemented (see ../README.md):
* customers.module.ts wiring only
* customers.controller.ts HTTP surface, no logic
* customers.service.ts business rules
* customers.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class CustomersModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of CustomersModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,34 @@
import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '@/common/decorators/public.decorator';
import { HealthService, type HealthCheckResult } from './health.service';
@ApiTags('health')
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
/**
* Liveness. Answers "is the process running?" and nothing else — it must not
* touch the database, or a brief DB blip would make the orchestrator kill
* otherwise-healthy containers.
*/
@Public()
@Get('live')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Liveness probe' })
live(): { status: 'ok' } {
return { status: 'ok' };
}
/** Readiness / deep check: should this instance receive traffic? */
@Public()
@Get()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Readiness probe with dependency status' })
check(): Promise<HealthCheckResult> {
return this.healthService.check();
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}
@@ -0,0 +1,67 @@
import { Inject, Injectable } from '@nestjs/common';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { RedisService } from '@/infrastructure/redis/redis.service';
export interface DependencyStatus {
status: 'up' | 'down';
latencyMs: number | null;
error?: string;
}
export interface HealthCheckResult {
status: 'ok' | 'degraded';
uptimeSeconds: number;
version: string;
environment: string;
dependencies: {
database: DependencyStatus;
redis: DependencyStatus;
};
}
@Injectable()
export class HealthService {
constructor(
@Inject(APP_CONFIG) private readonly config: AppConfig,
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
/**
* Deep check — used by dashboards and by `docker compose` dependency gates.
* Dependencies are probed in parallel so a slow one cannot mask another.
*/
async check(): Promise<HealthCheckResult> {
const [database, redis] = await Promise.all([
probe(() => this.prisma.ping()),
probe(() => this.redis.ping()),
]);
const healthy = database.status === 'up' && redis.status === 'up';
return {
status: healthy ? 'ok' : 'degraded',
uptimeSeconds: Math.round(process.uptime()),
version: this.config.app.version,
environment: this.config.app.env,
dependencies: { database, redis },
};
}
}
async function probe(fn: () => Promise<unknown>): Promise<DependencyStatus> {
const startedAt = performance.now();
try {
await fn();
return { status: 'up', latencyMs: Math.round(performance.now() - startedAt) };
} catch (error) {
return {
status: 'down',
latencyMs: null,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
/**
* InventoryModule — boundary declared, implementation pending.
*
* Owns (exclusively): `inventory_locations`, `stock_levels`, `stock_movements`
*
* Stock ledger, reservations and release. Consumes order events rather than being called by OrdersModule.
*
* EXTRACTION CANDIDATE: designed so it could become its own service. It must
* therefore never read another module’s tables directly, and it communicates
* outward through domain events.
*
* Anatomy once implemented (see ../README.md):
* inventory.module.ts wiring only
* inventory.controller.ts HTTP surface, no logic
* inventory.service.ts business rules
* inventory.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class InventoryModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of InventoryModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* MediaModule — boundary declared, implementation pending.
*
* Owns (exclusively): `media_assets`
*
* Issues presigned upload URLs and records metadata. Bytes never pass through the API and never enter PostgreSQL.
*
* Anatomy once implemented (see ../README.md):
* media.module.ts wiring only
* media.controller.ts HTTP surface, no logic
* media.service.ts business rules
* media.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class MediaModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of MediaModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
/**
* OrdersModule — boundary declared, implementation pending.
*
* Owns (exclusively): `orders`, `order_items`, `order_status_history` — milestone 2
*
* Order lines snapshot product name, variant title and price at purchase time. Never join to the live catalog for historical orders: yesterday’s receipt must not change when a price does.
*
* EXTRACTION CANDIDATE: designed so it could become its own service. It must
* therefore never read another module’s tables directly, and it communicates
* outward through domain events.
*
* Anatomy once implemented (see ../README.md):
* orders.module.ts wiring only
* orders.controller.ts HTTP surface, no logic
* orders.service.ts business rules
* orders.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class OrdersModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of OrdersModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
/**
* PaymentsModule — boundary declared, implementation pending.
*
* Owns (exclusively): `payments`, `payment_transactions`, `refunds` — milestone 3
*
* One PaymentProvider interface, one adapter per provider (VNPay, MoMo, ZaloPay, COD). Webhooks are signature-verified and idempotent by provider transaction id.
*
* EXTRACTION CANDIDATE: designed so it could become its own service. It must
* therefore never read another module’s tables directly, and it communicates
* outward through domain events.
*
* Anatomy once implemented (see ../README.md):
* payments.module.ts wiring only
* payments.controller.ts HTTP surface, no logic
* payments.service.ts business rules
* payments.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class PaymentsModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of PaymentsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* ProductVariantsModule — boundary declared, implementation pending.
*
* Owns (exclusively): `product_variants`, `product_variant_option_values`
*
* The purchasable unit. Split from ProductsModule because carts, orders, inventory and marketplace sync all talk to variants and none of them should pull in the whole product aggregate.
*
* Anatomy once implemented (see ../README.md):
* product-variants.module.ts wiring only
* product-variants.controller.ts HTTP surface, no logic
* product-variants.service.ts business rules
* product-variants.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class ProductVariantsModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of ProductVariantsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* ProductsModule — boundary declared, implementation pending.
*
* Owns (exclusively): `products`, `product_options`, `product_option_values`, `product_images`, `product_attributes`
*
* The catalog aggregate root. Every other module references a product by id and reads through this module’s public service.
*
* Anatomy once implemented (see ../README.md):
* products.module.ts wiring only
* products.controller.ts HTTP surface, no logic
* products.service.ts business rules
* products.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class ProductsModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of ProductsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* PromotionsModule — boundary declared, implementation pending.
*
* Owns (exclusively): `promotions`, `promotion_rules` — milestone 3
*
* Automatic, cart-level discounts. Pricing is calculated in one place so storefront, admin and invoices can never disagree.
*
* Anatomy once implemented (see ../README.md):
* promotions.module.ts wiring only
* promotions.controller.ts HTTP surface, no logic
* promotions.service.ts business rules
* promotions.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class PromotionsModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of PromotionsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,10 @@
/**
* Public surface of ReviewsModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* ReviewsModule — boundary declared, implementation pending.
*
* Owns (exclusively): `reviews` — milestone 3
*
* Verified-purchase reviews with moderation. Rating aggregates are denormalised onto the product read model, never computed per page view.
*
* Anatomy once implemented (see ../README.md):
* reviews.module.ts wiring only
* reviews.controller.ts HTTP surface, no logic
* reviews.service.ts business rules
* reviews.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class ReviewsModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of SearchModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
/**
* SearchModule — boundary declared, implementation pending.
*
* Owns (exclusively): Nothing. Read-only projection over the catalog.
*
* Starts as PostgreSQL full-text + trigram, which is genuinely enough below ~50k products. Behind a SearchProvider interface so swapping in OpenSearch is a provider change, not a rewrite of every listing page.
*
* EXTRACTION CANDIDATE: designed so it could become its own service. It must
* therefore never read another module’s tables directly, and it communicates
* outward through domain events.
*
* Anatomy once implemented (see ../README.md):
* search.module.ts wiring only
* search.controller.ts HTTP surface, no logic
* search.service.ts business rules
* search.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class SearchModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of UsersModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* UsersModule — boundary declared, implementation pending.
*
* Owns (exclusively): `users`, `roles`, `permissions`, `role_permissions`, `user_roles`
*
* Back-office identity and the RBAC administration surface. Owns the role/permission tables that AuthModule only reads through this module.
*
* Anatomy once implemented (see ../README.md):
* users.module.ts wiring only
* users.controller.ts HTTP surface, no logic
* users.service.ts business rules
* users.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class UsersModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of WishlistModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
*/
export {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* WishlistModule — boundary declared, implementation pending.
*
* Owns (exclusively): `wishlist_items` — milestone 2
*
* Saved variants per customer. Also the signal source for back-in-stock notifications.
*
* Anatomy once implemented (see ../README.md):
* wishlist.module.ts wiring only
* wishlist.controller.ts HTTP surface, no logic
* wishlist.service.ts business rules
* wishlist.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class WishlistModule {}