Basic Architecture of Sport Web
This commit is contained in:
@@ -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.
|
||||
@@ -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 {};
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user