NestJS + Prisma Development Skill
Project Structure
apps/api/
├── src/
│ ├── app.module.ts # Root module (imports all feature modules)
│ ├── main.ts # Bootstrap: ValidationPipe, CORS, Helmet, Swagger, Pino
│ ├── auth/ # Authentication & authorization (JWT, OAuth, OTP, RBAC)
│ ├── common/ # Shared: filters, interceptors, middleware, logger
│ ├── config/ # ConfigModule wrapper
│ ├── health/ # Health check (/api/health)
│ ├── mail/ # Email service (Handlebars templates, OTP, welcome)
│ ├── match/ # Match management & events
│ ├── prisma/ # PrismaService (driver adapter with pg.Pool)
│ ├── registration/ # Teams & players CRUD + CSV import (3 controllers)
│ ├── regulation/ # Season-scoped key-value regulations
│ ├── roster/ # Team-player assignments & jersey numbers
│ ├── scheduling/ # Round-robin schedule generation
│ ├── search/ # Global search across all entities
│ ├── season/ # Season CRUD & team registration workflow
│ ├── stadium/ # Stadium CRUD
│ ├── standings/ # League table, top scorers, card/team stats, exports
│ ├── upload/ # Image upload (Multer, 5MB, JPEG/PNG/WebP/GIF)
│ └── users/ # Admin user management
├── prisma/
│ ├── schema.prisma # 12 models, 10 enums
│ ├── seed.ts # Master seeder (chains all seed scripts)
│ ├── seed-teams.ts # 10 V-League teams with stadium mappings
│ ├── seed-players.ts # 100+ randomized Vietnamese + foreign players
│ ├── seed-stadiums.ts # 15 stadiums
│ ├── verify-demo-users.ts # Ensure 5 demo role accounts exist
│ ├── register-teams-to-season.ts
│ ├── cleanup-stale-matches.ts
│ └── migrations/ # 13 migrations
└── test/ # E2E specs (Supertest)
Core Technologies
| Layer |
Technology |
Version |
| Framework |
NestJS |
11.x |
| ORM |
Prisma (with @prisma/adapter-pg) |
7.x |
| Database |
PostgreSQL |
16 |
| Auth |
Passport (JWT, Google, Facebook) |
0.7.x |
| Rate Limit |
@nestjs/throttler |
6.x |
| Cache |
@nestjs/cache-manager |
3.x |
| Logging |
nestjs-pino |
4.x |
| Email |
@nestjs-modules/mailer + Handlebars |
|
| Testing |
Jest + ts-jest + Supertest |
|
Global Infrastructure (main.ts)
// Bootstrap configuration (already applied globally)
app.setGlobalPrefix('api'); // All routes: /api/...
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown props
forbidNonWhitelisted: true, // Reject unexpected props
transform: true, // Auto-transform to DTO classes
transformOptions: { enableImplicitConversion: true }, // "1" → 1
}),
);
app.useGlobalFilters(new HttpExceptionFilter()); // Unified error shape
app.useGlobalInterceptors(new LoggingInterceptor()); // Perf timing + Server-Timing header
app.enableCors({ origin: CORS_ORIGIN, credentials: true });
// Static assets
app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads/' });
// Swagger at /api/docs — 5 tags declared in DocumentBuilder
// Additional tags auto-discovered from @ApiTags() on controllers
SwaggerModule.setup('docs', app, document);
Global Guards (via APP_GUARD)
| Guard |
Scope |
Purpose |
JwtAuthGuard |
Global |
JWT Bearer auth; skipped on @Public() endpoints |
RolesGuard |
Global |
RBAC; checks @Roles() metadata vs req.user.role |
ThrottlerGuard |
Global |
Rate limiting: default(100/60s), short(20/1s), medium(50/10s), long(30/60s) |
Interceptors
| Interceptor |
Scope |
Purpose |
LoggingInterceptor |
Global |
Logs timing (🟢<100ms 🟡<500ms 🔴>500ms), Server-Timing header |
AuditLogInterceptor |
Available (not global) |
Logs POST/PATCH/PUT/DELETE mutations (entity, action, user, IP) |
CacheInterceptor |
StandingsController |
@nestjs/cache-manager with @CacheTTL(30000) |
Filters & Middleware
| Component |
Scope |
Purpose |
HttpExceptionFilter |
Global |
Unified error: {code, message, details, requestId, timestamp} |
SecurityMiddleware |
Module |
Helmet: CSP, HSTS, frameguard, noSniff, XSS filter |
Prisma Schema (12 Models, 10 Enums)
Enums
| Enum |
Values |
TeamStatus |
ACTIVE, INACTIVE |
PlayerPosition |
GK, DF, MF, FW |
PlayerType |
DOMESTIC, FOREIGN |
MatchStatus |
DRAFT, PUBLISHED, LOCKED, FINISHED, POSTPONED |
SeasonStatus |
UPCOMING, IN_PROGRESS, COMPLETED |
SeasonTeamStatus |
REGISTERED, APPROVED, REJECTED, WITHDRAWN |
EventType |
GOAL, OWN_GOAL, YELLOW_CARD, RED_CARD, SUBSTITUTION, PENALTY, PENALTY_MISS |
UserRole |
ADMIN, TEAM_MANAGER, REFEREE, SUPERVISOR, PUBLIC |
OtpType |
EMAIL_VERIFICATION, PASSWORD_RESET |
Models
| Model |
Table |
Key Fields |
User |
users |
id, email, passwordHash, role, emailVerified, name, avatarUrl, googleId, facebookId, roleId |
Role |
roles |
id, name, description |
OtpCode |
otp_codes |
id, code, type, userId, usedAt, expiresAt |
RefreshToken |
refresh_tokens |
id, tokenHash, userId, userAgent, ipAddress, deviceName, lastUsedAt, revokedAt, expiresAt |
Team |
teams |
id, name, shortName, city, logoUrl, status, stadiumId |
Player |
players |
id, fullName, dob, nationality, position, birthPlace, heightCm, weightKg, playerType |
Stadium |
stadiums |
id, name, address, city, capacity |
Season |
seasons |
id, name, year, status, startDate, endDate |
TeamPlayer |
team_players |
id, teamId, playerId, jerseyNumber, joinedAt, leftAt |
SeasonTeam |
season_teams |
id, seasonId, teamId, status, registeredAt, approvedAt |
Match |
matches |
id, roundNo, leg, seasonId, homeTeamId, awayTeamId, stadiumId, kickoffAt, homeScore, awayScore, status |
MatchEvent |
match_events |
id, matchId, minute, type, goalType, playerId, relatedPlayerId, teamId, note |
Regulation |
regulations |
id, seasonId, key, value, valueType |
Standing |
standings |
id, seasonId, teamId, played, win, draw, loss, goalsFor, goalsAgainst, goalDiff, points, rank |
Schema Conventions
model EntityName {
id String @id @default(uuid()) @db.Uuid // Always UUID
fieldName String @map("field_name") // snake_case in DB
status EnumType @default(VALUE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("table_name") // snake_case table
}
IMPORTANT: All PK and FK use @db.Uuid. Prisma 7 uses @prisma/adapter-pg driver adapter with raw pg.Pool.
Prisma Relation Names
WARNING: The relation field on Team and Player for the join table is named roster (from TeamPlayer[]), NOT teamPlayers. Always use roster in include and where:
// ✅ Correct
this.prisma.player.findMany({ include: { roster: { include: { team: true } } } });
// ❌ Wrong
this.prisma.player.findMany({ include: { teamPlayers: true } });
Prisma Enum Casting
TIP: When assigning string literals to Prisma-generated enum fields, use as never:
position: dto.position as never,
playerType: (dto.playerType ?? 'DOMESTIC') as never,
All Modules & Endpoints
1. AuthModule (/api/auth) — 19 endpoints
| Method |
Endpoint |
Auth |
Rate Limit |
Description |
| POST |
/auth/register |
Public |
5/min |
Register + send OTP email |
| POST |
/auth/verify-email |
Public |
5/10s |
Verify email with OTP |
| POST |
/auth/resend-otp |
Public |
3/min |
Resend verification OTP |
| POST |
/auth/forgot-password |
Public |
3/min |
Request password reset OTP |
| POST |
/auth/reset-password |
Public |
5/10s |
Reset password with OTP |
| POST |
/auth/login |
Public |
5/min |
Login, returns access+refresh tokens |
| POST |
/auth/refresh |
Public |
SkipThrottle |
Refresh access token |
| POST |
/auth/logout |
Public |
SkipThrottle |
Revoke refresh token |
| GET |
/auth/me |
JWT |
SkipThrottle |
Current user profile |
| POST |
/auth/change-password |
JWT |
SkipThrottle |
Change password |
| POST |
/auth/logout-all |
JWT |
SkipThrottle |
Revoke all sessions |
| PATCH |
/auth/profile |
JWT |
SkipThrottle |
Update name/avatarUrl |
| GET |
/auth/sessions |
JWT |
SkipThrottle |
List active sessions |
| DELETE |
/auth/sessions/:sessionId |
JWT |
SkipThrottle |
Revoke specific session |
| POST |
/auth/set-password |
JWT |
SkipThrottle |
Set password for OAuth users |
| GET |
/auth/google |
GoogleGuard |
SkipThrottle |
Start Google OAuth |
| GET |
/auth/google/callback |
GoogleGuard |
SkipThrottle |
Google OAuth callback |
| GET |
/auth/facebook |
FacebookGuard |
SkipThrottle |
Start Facebook OAuth |
| GET |
/auth/facebook/callback |
FacebookGuard |
SkipThrottle |
Facebook OAuth callback |
2. RegistrationModule (/api/teams, /api/players) — 11 endpoints
NOTE: The CSV import endpoint is in a separate PlayersImportController (players-import.controller.ts), not in PlayersController.
| Method |
Endpoint |
Auth |
Description |
| GET |
/teams |
Public |
List teams (paginated, search, filter by status) |
| GET |
/teams/:id |
Public |
Team detail (roster, matches, standings) |
| POST |
/teams |
ADMIN |
Create team |
| PATCH |
/teams/:id |
ADMIN |
Update team |
| DELETE |
/teams/:id |
ADMIN |
Delete team |
| GET |
/players |
Public |
List players (paginated, filter by search/position/nationality/teamId) |
| GET |
/players/:id |
Public |
Player detail (history, events) |
| POST |
/players |
ADMIN, TEAM_MANAGER |
Create player (age validation via regulation) |
| PATCH |
/players/:id |
ADMIN, TEAM_MANAGER |
Update player (handles team reassignment) |
| DELETE |
/players/:id |
ADMIN, TEAM_MANAGER |
Delete player |
| POST |
/players/import |
ADMIN |
CSV bulk import (max 2MB, per-row errors) — PlayersImportController |
3. MatchModule (/api/matches) — 6 endpoints
| Method |
Endpoint |
Auth |
Description |
| GET |
/matches |
All roles |
List matches (filter: seasonId, round, status, teamId) |
| GET |
/matches/:id |
All roles |
Match detail (events, teams, stadium) |
| POST |
/matches/:id/events |
ADMIN, REFEREE |
Add event (auto-recalculates scores) |
| DELETE |
/matches/:id/events/:eventId |
ADMIN, REFEREE |
Remove event |
| PATCH |
/matches/:id |
ADMIN |
Update match (stadium, kickoff, scores) |
| PATCH |
/matches/:id/status |
ADMIN |
Status transition (state machine) |
4. SchedulingModule (/api/schedule) — 3 endpoints
| Method |
Endpoint |
Auth |
Description |
| POST |
/schedule/generate |
ADMIN |
Auto-generate double round-robin schedule |
| POST |
/schedule/publish |
ADMIN |
Bulk publish DRAFT → PUBLISHED |
| GET |
/schedule |
All roles |
Get schedule with relations |
5. SeasonModule (/api/seasons) — 11 endpoints
| Method |
Endpoint |
Auth |
Description |
| GET |
/seasons |
Public |
List all seasons (ordered by year desc) |
| GET |
/seasons/current |
Public |
Get IN_PROGRESS season |
| GET |
/seasons/:id |
Public |
Season detail |
| POST |
/seasons |
ADMIN |
Create season |
| PATCH |
/seasons/:id |
ADMIN |
Update season |
| DELETE |
/seasons/:id |
ADMIN |
Delete season |
| PATCH |
/seasons/:id/status |
ADMIN |
Status transition (state machine) |
| GET |
/seasons/:seasonId/teams |
Public |
List registered teams |
| POST |
/seasons/:seasonId/teams |
ADMIN |
Register team to season |
| PATCH |
/seasons/:seasonId/teams/:teamId/status |
ADMIN |
Update registration status |
| DELETE |
/seasons/:seasonId/teams/:teamId |
ADMIN |
Remove team from season |
6. StadiumModule (/api/stadiums) — 5 endpoints
| Method |
Endpoint |
Auth |
Description |
| GET |
/stadiums |
Public |
List all stadiums |
| GET |
/stadiums/:id |
Public |
Stadium detail (teams, matches) |
| POST |
/stadiums |
ADMIN |
Create stadium |
| PATCH |
/stadiums/:id |
ADMIN |
Update stadium |
| DELETE |
/stadiums/:id |
ADMIN |
Delete stadium |
7. RosterModule (/api/teams/:teamId/roster) — 4 endpoints
| Method |
Endpoint |
Auth |
Description |
| GET |
/teams/:teamId/roster |
Public |
Get team roster |
| POST |
/teams/:teamId/roster |
ADMIN, TEAM_MANAGER |
Add player (validates max roster, foreign limit) |
| PATCH |
/teams/:teamId/roster/:playerId |
ADMIN, TEAM_MANAGER |
Update jersey number |
| DELETE |
/teams/:teamId/roster/:playerId |
ADMIN, TEAM_MANAGER |
Soft remove (sets leftAt) |
8. RegulationModule (/api/seasons/:seasonId/regulations) — 5 endpoints
| Method |
Endpoint |
Auth |
Description |
| GET |
/seasons/:seasonId/regulations |
Public |
List regulations |
| GET |
/seasons/:seasonId/regulations/:key |
Public |
Get by key |
| PUT |
/seasons/:seasonId/regulations |
ADMIN |
Upsert regulation |
| DELETE |
/seasons/:seasonId/regulations/:key |
ADMIN |
Delete regulation |
| POST |
/seasons/:seasonId/regulations/seed-defaults |
ADMIN |
Seed 9 default regulations |
9. StandingsModule (/api/standings) — 12 endpoints
| Method |
Endpoint |
Auth |
Description |
| GET |
/standings |
Public |
League table (cached 30s) |
| GET |
/standings/:seasonId |
Public |
Standings by season (param-based) |
| GET |
/standings/top-scorers |
Public |
Top scorers |
| GET |
/standings/card-stats |
Public |
Card statistics |
| GET |
/standings/team-stats |
Public |
Team aggregated stats (inc. clean sheets) |
| GET |
/standings/head-to-head |
Public |
Head-to-head between 2 teams |
| GET |
/standings/player-stats/:playerId |
Public |
Individual player stats |
| GET |
/standings/export/standings |
Public |
CSV export - standings |
| GET |
/standings/export/top-scorers |
Public |
CSV export - scorers |
| GET |
/standings/export/card-stats |
Public |
CSV export - cards |
| GET |
/standings/export/team-stats |
Public |
CSV export - team stats |
10. UsersModule (/api/users) — 4 endpoints (ADMIN only)
| Method |
Endpoint |
Auth |
Description |
| GET |
/users |
ADMIN |
List all users |
| POST |
/users |
ADMIN |
Create user (pre-verified) |
| PATCH |
/users/:id/role |
ADMIN |
Update user role |
| DELETE |
/users/:id |
ADMIN |
Delete user + related |
11. UploadModule (/api/upload) — 1 endpoint
| Method |
Endpoint |
Auth |
Description |
| POST |
/upload/image |
ADMIN, TEAM_MANAGER |
Upload image (JPEG/PNG/WebP/GIF, max 5MB) |
12. SearchModule (/api/search) — 1 endpoint
| Method |
Endpoint |
Auth |
Rate Limit |
Description |
| GET |
/search?q=...&limit=... |
Public |
10/5s |
Global search: teams, players, matches, stadiums, seasons |
13. HealthModule (/api/health) — 1 endpoint
| Method |
Endpoint |
Auth |
Description |
| GET |
/health |
Public (SkipThrottle) |
DB connectivity + memory heap (150MB) |
14. MailModule (internal service, no controller)
sendEmailVerificationOtp, sendPasswordResetOtp, sendWelcomeEmail
- Dev mode:
MAIL_SKIP_SEND=true logs OTP to console
- Handlebars templates:
email-verification.hbs, password-reset.hbs, welcome.hbs
15. PrismaModule (internal service)
PrismaService extends PrismaClient with @prisma/adapter-pg driver adapter
- Lifecycle:
onModuleInit → $connect(), onModuleDestroy → $disconnect()
Guards & Decorators
Guards
| Guard |
Location |
Purpose |
JwtAuthGuard |
auth/guards/jwt-auth.guard.ts |
JWT Bearer; skips @Public() |
RolesGuard |
auth/guards/roles.guard.ts |
RBAC vs @Roles() metadata |
GoogleAuthGuard |
auth/guards/google-auth.guard.ts |
Passport Google OAuth 2.0 |
FacebookAuthGuard |
auth/guards/facebook-auth.guard.ts |
Passport Facebook OAuth |
ThrottlerGuard |
Global (APP_GUARD) |
Multi-config rate limiting |
Decorators
| Decorator |
Usage |
@Public() |
Skip JWT auth on endpoint |
@Roles(...) |
Require specific UserRole(s) |
@CurrentUser() |
Extract req.user as param decorator |
@SkipThrottle() |
Bypass rate limiting |
@Throttle() |
Override rate limit config per endpoint |
@CacheTTL(ms) |
Set cache duration for endpoint |
Strategies
| Strategy |
Purpose |
JwtStrategy |
Validate JWT from Authorization: Bearer |
GoogleStrategy |
Google OAuth 2.0 via Passport |
FacebookStrategy |
Facebook OAuth via Passport |
Cross-Module Dependencies
MatchModule → imports StandingsModule, RegulationModule
- StandingsService: auto-recalculate on match FINISHED
- RegulationHelper: MAX_GOAL_TIME validation
RegistrationModule → imports RegulationModule
- RegulationHelper: MIN_AGE, MAX_AGE validation
RosterModule → imports RegulationModule
- RegulationHelper: MAX_ROSTER, MAX_FOREIGN_PLAYERS validation
RegulationModule → exports RegulationService, RegulationHelper
- RegulationHelper.getNumericValue(seasonId, key, fallback)
Common Module (src/common/)
| Directory |
File |
Purpose |
errors/ |
app-error.ts |
Custom AppError class with error codes |
filters/ |
http-exception.filter.ts |
Unified error response shape (global filter) |
interceptors/ |
logging.interceptor.ts |
Request/response performance logging |
interceptors/ |
audit-log.interceptor.ts |
Optional mutation logging |
logger/ |
logger.module.ts |
nestjs-pino structured logging |
middleware/ |
security.middleware.ts |
Helmet security headers |
DTO & Validation Patterns
// Create DTO
export class CreateTeamDto {
@ApiProperty({ description: 'Team name', example: 'Hoàng Anh Gia Lai' })
@IsString()
@IsNotEmpty()
name: string;
@ApiPropertyOptional({ enum: TeamStatus, default: TeamStatus.ACTIVE })
@IsOptional()
@IsEnum(TeamStatus)
status?: TeamStatus;
}
// Update DTO (partial of Create)
export class UpdateTeamDto extends PartialType(CreateTeamDto) {}
// Barrel export: dto/index.ts
export * from './create-team.dto';
export * from './update-team.dto';
Testing
23 test suites covering services, controllers, and E2E.
Test Pattern
describe('ModuleNameService', () => {
let service: ModuleNameService;
let prisma: PrismaService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
ModuleNameService,
{
provide: PrismaService,
useValue: {
modelName: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
},
},
},
],
}).compile();
service = module.get(ModuleNameService);
prisma = module.get(PrismaService);
});
});
Auth Service Tests: jest.mock('bcrypt') at module level
E2E Tests: test/*.e2e-spec.ts with Supertest
Environment Variables
DATABASE_URL="postgresql://user:password@localhost:5432/vleague"
PORT=8080
CORS_ORIGIN=http://localhost:5173
JWT_SECRET=your-secret
JWT_REFRESH_SECRET=your-refresh-secret
JWT_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USER=...
MAIL_PASS=...
MAIL_FROM=noreply@vleague.local
MAIL_SKIP_SEND=true # Dev: log OTP to console
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_CALLBACK_URL=http://localhost:8080/api/auth/google/callback
FACEBOOK_APP_ID=...
FACEBOOK_APP_SECRET=...
FACEBOOK_CALLBACK_URL=http://localhost:8080/api/auth/facebook/callback
FRONTEND_URL=http://localhost:5173
Common Commands
cd apps/api
pnpm dev # Start dev server (watch mode)
pnpm test # Unit tests (23 suites)
pnpm test:e2e # E2E tests
pnpm test:cov # Coverage report
pnpm dlx prisma migrate dev # Create migration
pnpm dlx prisma generate # Generate Prisma client
pnpm dlx prisma studio # Open Prisma Studio GUI
pnpm run db:seed # Seed database
pnpm lint # ESLint
Notable Patterns
- Prisma 7 Driver Adapter: Uses
@prisma/adapter-pg with raw pg.Pool instead of binary engine
- Vietnamese messages: All user-facing error messages in Vietnamese
- CSV export with BOM:
toCsv() helper prepends \uFEFF for Excel Vietnamese support
- Structured error codes:
AUTH_EMAIL_EXISTS, AUTH_OTP_INVALID, etc. for deterministic client handling
- Device-aware sessions: Refresh tokens track userAgent, ipAddress, deviceName, lastUsedAt
- OAuth account linking: Google/Facebook auto-link to existing email; users can set password post-OAuth
- WebSocket deps present:
@nestjs/websockets + socket.io in deps but no gateway implemented yet
- Regulation-driven rules: Core limits (age, roster, foreign players, goal time) configurable per-season
- RegulationHelper fallback: DB value → defaults → hardcoded fallback
- Standings computed live: From match events, not from Standing model directly
Converted and distributed by TomeVault — claim your Tome and manage your conversions.
1---2name: nestjs-prisma-development3description: Complete guide for the SE104_VLEAGUE NestJS backend — modules, endpoints, Prisma schema, guards, interceptors, and patterns Use when this capability is needed.4---56# NestJS + Prisma Development Skill78## Project Structure910```11apps/api/12├── src/13│ ├── app.module.ts # Root module (imports all feature modules)14│ ├── main.ts # Bootstrap: ValidationPipe, CORS, Helmet, Swagger, Pino15│ ├── auth/ # Authentication & authorization (JWT, OAuth, OTP, RBAC)16│ ├── common/ # Shared: filters, interceptors, middleware, logger17│ ├── config/ # ConfigModule wrapper18│ ├── health/ # Health check (/api/health)19│ ├── mail/ # Email service (Handlebars templates, OTP, welcome)20│ ├── match/ # Match management & events21│ ├── prisma/ # PrismaService (driver adapter with pg.Pool)22│ ├── registration/ # Teams & players CRUD + CSV import (3 controllers)23│ ├── regulation/ # Season-scoped key-value regulations24│ ├── roster/ # Team-player assignments & jersey numbers25│ ├── scheduling/ # Round-robin schedule generation26│ ├── search/ # Global search across all entities27│ ├── season/ # Season CRUD & team registration workflow28│ ├── stadium/ # Stadium CRUD29│ ├── standings/ # League table, top scorers, card/team stats, exports30│ ├── upload/ # Image upload (Multer, 5MB, JPEG/PNG/WebP/GIF)31│ └── users/ # Admin user management32├── prisma/33│ ├── schema.prisma # 12 models, 10 enums34│ ├── seed.ts # Master seeder (chains all seed scripts)35│ ├── seed-teams.ts # 10 V-League teams with stadium mappings36│ ├── seed-players.ts # 100+ randomized Vietnamese + foreign players37│ ├── seed-stadiums.ts # 15 stadiums38│ ├── verify-demo-users.ts # Ensure 5 demo role accounts exist39│ ├── register-teams-to-season.ts40│ ├── cleanup-stale-matches.ts41│ └── migrations/ # 13 migrations42└── test/ # E2E specs (Supertest)43```4445## Core Technologies4647| Layer | Technology | Version |48| ---------- | ----------------------------------- | ------- |49| Framework | NestJS | 11.x |50| ORM | Prisma (with `@prisma/adapter-pg`) | 7.x |51| Database | PostgreSQL | 16 |52| Auth | Passport (JWT, Google, Facebook) | 0.7.x |53| Rate Limit | @nestjs/throttler | 6.x |54| Cache | @nestjs/cache-manager | 3.x |55| Logging | nestjs-pino | 4.x |56| Email | @nestjs-modules/mailer + Handlebars | |57| Testing | Jest + ts-jest + Supertest | |5859## Global Infrastructure (main.ts)6061```typescript62// Bootstrap configuration (already applied globally)63app.setGlobalPrefix('api'); // All routes: /api/...64app.useGlobalPipes(65 new ValidationPipe({66 whitelist: true, // Strip unknown props67 forbidNonWhitelisted: true, // Reject unexpected props68 transform: true, // Auto-transform to DTO classes69 transformOptions: { enableImplicitConversion: true }, // "1" → 170 }),71);72app.useGlobalFilters(new HttpExceptionFilter()); // Unified error shape73app.useGlobalInterceptors(new LoggingInterceptor()); // Perf timing + Server-Timing header74app.enableCors({ origin: CORS_ORIGIN, credentials: true });7576// Static assets77app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads/' });7879// Swagger at /api/docs — 5 tags declared in DocumentBuilder80// Additional tags auto-discovered from @ApiTags() on controllers81SwaggerModule.setup('docs', app, document);82```8384### Global Guards (via APP_GUARD)8586| Guard | Scope | Purpose |87| ---------------- | ------ | --------------------------------------------------------------------------- |88| `JwtAuthGuard` | Global | JWT Bearer auth; skipped on `@Public()` endpoints |89| `RolesGuard` | Global | RBAC; checks `@Roles()` metadata vs `req.user.role` |90| `ThrottlerGuard` | Global | Rate limiting: default(100/60s), short(20/1s), medium(50/10s), long(30/60s) |9192### Interceptors9394| Interceptor | Scope | Purpose |95| --------------------- | ---------------------- | ---------------------------------------------------------------- |96| `LoggingInterceptor` | Global | Logs timing (🟢<100ms 🟡<500ms 🔴>500ms), `Server-Timing` header |97| `AuditLogInterceptor` | Available (not global) | Logs POST/PATCH/PUT/DELETE mutations (entity, action, user, IP) |98| `CacheInterceptor` | StandingsController | `@nestjs/cache-manager` with `@CacheTTL(30000)` |99100### Filters & Middleware101102| Component | Scope | Purpose |103| --------------------- | ------ | --------------------------------------------------------------- |104| `HttpExceptionFilter` | Global | Unified error: `{code, message, details, requestId, timestamp}` |105| `SecurityMiddleware` | Module | Helmet: CSP, HSTS, frameguard, noSniff, XSS filter |106107---108109## Prisma Schema (12 Models, 10 Enums)110111### Enums112113| Enum | Values |114| ------------------ | -------------------------------------------------------------------------- |115| `TeamStatus` | ACTIVE, INACTIVE |116| `PlayerPosition` | GK, DF, MF, FW |117| `PlayerType` | DOMESTIC, FOREIGN |118| `MatchStatus` | DRAFT, PUBLISHED, LOCKED, FINISHED, POSTPONED |119| `SeasonStatus` | UPCOMING, IN_PROGRESS, COMPLETED |120| `SeasonTeamStatus` | REGISTERED, APPROVED, REJECTED, WITHDRAWN |121| `EventType` | GOAL, OWN_GOAL, YELLOW_CARD, RED_CARD, SUBSTITUTION, PENALTY, PENALTY_MISS |122| `UserRole` | ADMIN, TEAM_MANAGER, REFEREE, SUPERVISOR, PUBLIC |123| `OtpType` | EMAIL_VERIFICATION, PASSWORD_RESET |124125### Models126127| Model | Table | Key Fields |128| -------------- | ---------------- | ------------------------------------------------------------------------------------------------------ |129| `User` | `users` | id, email, passwordHash, role, emailVerified, name, avatarUrl, googleId, facebookId, roleId |130| `Role` | `roles` | id, name, description |131| `OtpCode` | `otp_codes` | id, code, type, userId, usedAt, expiresAt |132| `RefreshToken` | `refresh_tokens` | id, tokenHash, userId, userAgent, ipAddress, deviceName, lastUsedAt, revokedAt, expiresAt |133| `Team` | `teams` | id, name, shortName, city, logoUrl, status, stadiumId |134| `Player` | `players` | id, fullName, dob, nationality, position, birthPlace, heightCm, weightKg, playerType |135| `Stadium` | `stadiums` | id, name, address, city, capacity |136| `Season` | `seasons` | id, name, year, status, startDate, endDate |137| `TeamPlayer` | `team_players` | id, teamId, playerId, jerseyNumber, joinedAt, leftAt |138| `SeasonTeam` | `season_teams` | id, seasonId, teamId, status, registeredAt, approvedAt |139| `Match` | `matches` | id, roundNo, leg, seasonId, homeTeamId, awayTeamId, stadiumId, kickoffAt, homeScore, awayScore, status |140| `MatchEvent` | `match_events` | id, matchId, minute, type, goalType, playerId, relatedPlayerId, teamId, note |141| `Regulation` | `regulations` | id, seasonId, key, value, valueType |142| `Standing` | `standings` | id, seasonId, teamId, played, win, draw, loss, goalsFor, goalsAgainst, goalDiff, points, rank |143144### Schema Conventions145146```prisma147model EntityName {148 id String @id @default(uuid()) @db.Uuid // Always UUID149 fieldName String @map("field_name") // snake_case in DB150 status EnumType @default(VALUE)151 createdAt DateTime @default(now()) @map("created_at")152 updatedAt DateTime @updatedAt @map("updated_at")153 @@map("table_name") // snake_case table154}155```156157> **IMPORTANT**: All PK and FK use `@db.Uuid`. Prisma 7 uses `@prisma/adapter-pg` driver adapter with raw `pg.Pool`.158159### Prisma Relation Names160161> **WARNING**: The relation field on `Team` and `Player` for the join table is named `roster` (from `TeamPlayer[]`), NOT `teamPlayers`. Always use `roster` in `include` and `where`:162>163> ```typescript164> // ✅ Correct165> this.prisma.player.findMany({ include: { roster: { include: { team: true } } } });166> // ❌ Wrong167> this.prisma.player.findMany({ include: { teamPlayers: true } });168> ```169170### Prisma Enum Casting171172> **TIP**: When assigning string literals to Prisma-generated enum fields, use `as never`:173>174> ```typescript175> position: dto.position as never,176> playerType: (dto.playerType ?? 'DOMESTIC') as never,177> ```178179---180181## All Modules & Endpoints182183### 1. AuthModule (`/api/auth`) — 19 endpoints184185| Method | Endpoint | Auth | Rate Limit | Description |186| ------ | --------------------------- | ------------- | ------------ | ------------------------------------ |187| POST | `/auth/register` | Public | 5/min | Register + send OTP email |188| POST | `/auth/verify-email` | Public | 5/10s | Verify email with OTP |189| POST | `/auth/resend-otp` | Public | 3/min | Resend verification OTP |190| POST | `/auth/forgot-password` | Public | 3/min | Request password reset OTP |191| POST | `/auth/reset-password` | Public | 5/10s | Reset password with OTP |192| POST | `/auth/login` | Public | 5/min | Login, returns access+refresh tokens |193| POST | `/auth/refresh` | Public | SkipThrottle | Refresh access token |194| POST | `/auth/logout` | Public | SkipThrottle | Revoke refresh token |195| GET | `/auth/me` | JWT | SkipThrottle | Current user profile |196| POST | `/auth/change-password` | JWT | SkipThrottle | Change password |197| POST | `/auth/logout-all` | JWT | SkipThrottle | Revoke all sessions |198| PATCH | `/auth/profile` | JWT | SkipThrottle | Update name/avatarUrl |199| GET | `/auth/sessions` | JWT | SkipThrottle | List active sessions |200| DELETE | `/auth/sessions/:sessionId` | JWT | SkipThrottle | Revoke specific session |201| POST | `/auth/set-password` | JWT | SkipThrottle | Set password for OAuth users |202| GET | `/auth/google` | GoogleGuard | SkipThrottle | Start Google OAuth |203| GET | `/auth/google/callback` | GoogleGuard | SkipThrottle | Google OAuth callback |204| GET | `/auth/facebook` | FacebookGuard | SkipThrottle | Start Facebook OAuth |205| GET | `/auth/facebook/callback` | FacebookGuard | SkipThrottle | Facebook OAuth callback |206207### 2. RegistrationModule (`/api/teams`, `/api/players`) — 11 endpoints208209> **NOTE**: The CSV import endpoint is in a **separate** `PlayersImportController` (`players-import.controller.ts`), not in `PlayersController`.210211| Method | Endpoint | Auth | Description |212| ------ | ----------------- | ------------------- | ---------------------------------------------------------------------- |213| GET | `/teams` | Public | List teams (paginated, search, filter by status) |214| GET | `/teams/:id` | Public | Team detail (roster, matches, standings) |215| POST | `/teams` | ADMIN | Create team |216| PATCH | `/teams/:id` | ADMIN | Update team |217| DELETE | `/teams/:id` | ADMIN | Delete team |218| GET | `/players` | Public | List players (paginated, filter by search/position/nationality/teamId) |219| GET | `/players/:id` | Public | Player detail (history, events) |220| POST | `/players` | ADMIN, TEAM_MANAGER | Create player (age validation via regulation) |221| PATCH | `/players/:id` | ADMIN, TEAM_MANAGER | Update player (handles team reassignment) |222| DELETE | `/players/:id` | ADMIN, TEAM_MANAGER | Delete player |223| POST | `/players/import` | ADMIN | CSV bulk import (max 2MB, per-row errors) — `PlayersImportController` |224225### 3. MatchModule (`/api/matches`) — 6 endpoints226227| Method | Endpoint | Auth | Description |228| ------ | ------------------------------ | -------------- | ------------------------------------------------------ |229| GET | `/matches` | All roles | List matches (filter: seasonId, round, status, teamId) |230| GET | `/matches/:id` | All roles | Match detail (events, teams, stadium) |231| POST | `/matches/:id/events` | ADMIN, REFEREE | Add event (auto-recalculates scores) |232| DELETE | `/matches/:id/events/:eventId` | ADMIN, REFEREE | Remove event |233| PATCH | `/matches/:id` | ADMIN | Update match (stadium, kickoff, scores) |234| PATCH | `/matches/:id/status` | ADMIN | Status transition (state machine) |235236### 4. SchedulingModule (`/api/schedule`) — 3 endpoints237238| Method | Endpoint | Auth | Description |239| ------ | -------------------- | --------- | ----------------------------------------- |240| POST | `/schedule/generate` | ADMIN | Auto-generate double round-robin schedule |241| POST | `/schedule/publish` | ADMIN | Bulk publish DRAFT → PUBLISHED |242| GET | `/schedule` | All roles | Get schedule with relations |243244### 5. SeasonModule (`/api/seasons`) — 11 endpoints245246| Method | Endpoint | Auth | Description |247| ------ | ----------------------------------------- | ------ | --------------------------------------- |248| GET | `/seasons` | Public | List all seasons (ordered by year desc) |249| GET | `/seasons/current` | Public | Get IN_PROGRESS season |250| GET | `/seasons/:id` | Public | Season detail |251| POST | `/seasons` | ADMIN | Create season |252| PATCH | `/seasons/:id` | ADMIN | Update season |253| DELETE | `/seasons/:id` | ADMIN | Delete season |254| PATCH | `/seasons/:id/status` | ADMIN | Status transition (state machine) |255| GET | `/seasons/:seasonId/teams` | Public | List registered teams |256| POST | `/seasons/:seasonId/teams` | ADMIN | Register team to season |257| PATCH | `/seasons/:seasonId/teams/:teamId/status` | ADMIN | Update registration status |258| DELETE | `/seasons/:seasonId/teams/:teamId` | ADMIN | Remove team from season |259260### 6. StadiumModule (`/api/stadiums`) — 5 endpoints261262| Method | Endpoint | Auth | Description |263| ------ | --------------- | ------ | ------------------------------- |264| GET | `/stadiums` | Public | List all stadiums |265| GET | `/stadiums/:id` | Public | Stadium detail (teams, matches) |266| POST | `/stadiums` | ADMIN | Create stadium |267| PATCH | `/stadiums/:id` | ADMIN | Update stadium |268| DELETE | `/stadiums/:id` | ADMIN | Delete stadium |269270### 7. RosterModule (`/api/teams/:teamId/roster`) — 4 endpoints271272| Method | Endpoint | Auth | Description |273| ------ | --------------------------------- | ------------------- | ------------------------------------------------ |274| GET | `/teams/:teamId/roster` | Public | Get team roster |275| POST | `/teams/:teamId/roster` | ADMIN, TEAM_MANAGER | Add player (validates max roster, foreign limit) |276| PATCH | `/teams/:teamId/roster/:playerId` | ADMIN, TEAM_MANAGER | Update jersey number |277| DELETE | `/teams/:teamId/roster/:playerId` | ADMIN, TEAM_MANAGER | Soft remove (sets leftAt) |278279### 8. RegulationModule (`/api/seasons/:seasonId/regulations`) — 5 endpoints280281| Method | Endpoint | Auth | Description |282| ------ | ---------------------------------------------- | ------ | -------------------------- |283| GET | `/seasons/:seasonId/regulations` | Public | List regulations |284| GET | `/seasons/:seasonId/regulations/:key` | Public | Get by key |285| PUT | `/seasons/:seasonId/regulations` | ADMIN | Upsert regulation |286| DELETE | `/seasons/:seasonId/regulations/:key` | ADMIN | Delete regulation |287| POST | `/seasons/:seasonId/regulations/seed-defaults` | ADMIN | Seed 9 default regulations |288289### 9. StandingsModule (`/api/standings`) — 12 endpoints290291| Method | Endpoint | Auth | Description |292| ------ | ----------------------------------- | ------ | ----------------------------------------- |293| GET | `/standings` | Public | League table (cached 30s) |294| GET | `/standings/:seasonId` | Public | Standings by season (param-based) |295| GET | `/standings/top-scorers` | Public | Top scorers |296| GET | `/standings/card-stats` | Public | Card statistics |297| GET | `/standings/team-stats` | Public | Team aggregated stats (inc. clean sheets) |298| GET | `/standings/head-to-head` | Public | Head-to-head between 2 teams |299| GET | `/standings/player-stats/:playerId` | Public | Individual player stats |300| GET | `/standings/export/standings` | Public | CSV export - standings |301| GET | `/standings/export/top-scorers` | Public | CSV export - scorers |302| GET | `/standings/export/card-stats` | Public | CSV export - cards |303| GET | `/standings/export/team-stats` | Public | CSV export - team stats |304305### 10. UsersModule (`/api/users`) — 4 endpoints (ADMIN only)306307| Method | Endpoint | Auth | Description |308| ------ | ----------------- | ----- | -------------------------- |309| GET | `/users` | ADMIN | List all users |310| POST | `/users` | ADMIN | Create user (pre-verified) |311| PATCH | `/users/:id/role` | ADMIN | Update user role |312| DELETE | `/users/:id` | ADMIN | Delete user + related |313314### 11. UploadModule (`/api/upload`) — 1 endpoint315316| Method | Endpoint | Auth | Description |317| ------ | --------------- | ------------------- | ----------------------------------------- |318| POST | `/upload/image` | ADMIN, TEAM_MANAGER | Upload image (JPEG/PNG/WebP/GIF, max 5MB) |319320### 12. SearchModule (`/api/search`) — 1 endpoint321322| Method | Endpoint | Auth | Rate Limit | Description |323| ------ | ------------------------- | ------ | ---------- | --------------------------------------------------------- |324| GET | `/search?q=...&limit=...` | Public | 10/5s | Global search: teams, players, matches, stadiums, seasons |325326### 13. HealthModule (`/api/health`) — 1 endpoint327328| Method | Endpoint | Auth | Description |329| ------ | --------- | --------------------- | ------------------------------------- |330| GET | `/health` | Public (SkipThrottle) | DB connectivity + memory heap (150MB) |331332### 14. MailModule (internal service, no controller)333334- `sendEmailVerificationOtp`, `sendPasswordResetOtp`, `sendWelcomeEmail`335- Dev mode: `MAIL_SKIP_SEND=true` logs OTP to console336- Handlebars templates: `email-verification.hbs`, `password-reset.hbs`, `welcome.hbs`337338### 15. PrismaModule (internal service)339340- `PrismaService` extends `PrismaClient` with `@prisma/adapter-pg` driver adapter341- Lifecycle: `onModuleInit` → `$connect()`, `onModuleDestroy` → `$disconnect()`342343---344345## Guards & Decorators346347### Guards348349| Guard | Location | Purpose |350| ------------------- | ------------------------------------ | ----------------------------- |351| `JwtAuthGuard` | `auth/guards/jwt-auth.guard.ts` | JWT Bearer; skips `@Public()` |352| `RolesGuard` | `auth/guards/roles.guard.ts` | RBAC vs `@Roles()` metadata |353| `GoogleAuthGuard` | `auth/guards/google-auth.guard.ts` | Passport Google OAuth 2.0 |354| `FacebookAuthGuard` | `auth/guards/facebook-auth.guard.ts` | Passport Facebook OAuth |355| `ThrottlerGuard` | Global (APP_GUARD) | Multi-config rate limiting |356357### Decorators358359| Decorator | Usage |360| ----------------- | --------------------------------------- |361| `@Public()` | Skip JWT auth on endpoint |362| `@Roles(...)` | Require specific UserRole(s) |363| `@CurrentUser()` | Extract `req.user` as param decorator |364| `@SkipThrottle()` | Bypass rate limiting |365| `@Throttle()` | Override rate limit config per endpoint |366| `@CacheTTL(ms)` | Set cache duration for endpoint |367368### Strategies369370| Strategy | Purpose |371| ------------------ | ----------------------------------------- |372| `JwtStrategy` | Validate JWT from `Authorization: Bearer` |373| `GoogleStrategy` | Google OAuth 2.0 via Passport |374| `FacebookStrategy` | Facebook OAuth via Passport |375376---377378## Cross-Module Dependencies379380```381MatchModule → imports StandingsModule, RegulationModule382 - StandingsService: auto-recalculate on match FINISHED383 - RegulationHelper: MAX_GOAL_TIME validation384385RegistrationModule → imports RegulationModule386 - RegulationHelper: MIN_AGE, MAX_AGE validation387388RosterModule → imports RegulationModule389 - RegulationHelper: MAX_ROSTER, MAX_FOREIGN_PLAYERS validation390391RegulationModule → exports RegulationService, RegulationHelper392 - RegulationHelper.getNumericValue(seasonId, key, fallback)393```394395---396397## Common Module (`src/common/`)398399| Directory | File | Purpose |400| --------------- | -------------------------- | -------------------------------------------- |401| `errors/` | `app-error.ts` | Custom `AppError` class with error codes |402| `filters/` | `http-exception.filter.ts` | Unified error response shape (global filter) |403| `interceptors/` | `logging.interceptor.ts` | Request/response performance logging |404| `interceptors/` | `audit-log.interceptor.ts` | Optional mutation logging |405| `logger/` | `logger.module.ts` | `nestjs-pino` structured logging |406| `middleware/` | `security.middleware.ts` | Helmet security headers |407408---409410## DTO & Validation Patterns411412```typescript413// Create DTO414export class CreateTeamDto {415 @ApiProperty({ description: 'Team name', example: 'Hoàng Anh Gia Lai' })416 @IsString()417 @IsNotEmpty()418 name: string;419420 @ApiPropertyOptional({ enum: TeamStatus, default: TeamStatus.ACTIVE })421 @IsOptional()422 @IsEnum(TeamStatus)423 status?: TeamStatus;424}425426// Update DTO (partial of Create)427export class UpdateTeamDto extends PartialType(CreateTeamDto) {}428429// Barrel export: dto/index.ts430export * from './create-team.dto';431export * from './update-team.dto';432```433434---435436## Testing437438**23 test suites** covering services, controllers, and E2E.439440### Test Pattern441442```typescript443describe('ModuleNameService', () => {444 let service: ModuleNameService;445 let prisma: PrismaService;446447 beforeEach(async () => {448 const module = await Test.createTestingModule({449 providers: [450 ModuleNameService,451 {452 provide: PrismaService,453 useValue: {454 modelName: {455 findMany: jest.fn(),456 findUnique: jest.fn(),457 create: jest.fn(),458 },459 },460 },461 ],462 }).compile();463 service = module.get(ModuleNameService);464 prisma = module.get(PrismaService);465 });466});467```468469### Auth Service Tests: `jest.mock('bcrypt')` at module level470471### E2E Tests: `test/*.e2e-spec.ts` with Supertest472473---474475## Environment Variables476477```env478DATABASE_URL="postgresql://user:password@localhost:5432/vleague"479PORT=8080480CORS_ORIGIN=http://localhost:5173481JWT_SECRET=your-secret482JWT_REFRESH_SECRET=your-refresh-secret483JWT_EXPIRATION=15m484JWT_REFRESH_EXPIRATION=7d485MAIL_HOST=smtp.gmail.com486MAIL_PORT=587487MAIL_USER=...488MAIL_PASS=...489MAIL_FROM=noreply@vleague.local490MAIL_SKIP_SEND=true # Dev: log OTP to console491GOOGLE_CLIENT_ID=...492GOOGLE_CLIENT_SECRET=...493GOOGLE_CALLBACK_URL=http://localhost:8080/api/auth/google/callback494FACEBOOK_APP_ID=...495FACEBOOK_APP_SECRET=...496FACEBOOK_CALLBACK_URL=http://localhost:8080/api/auth/facebook/callback497FRONTEND_URL=http://localhost:5173498```499500---501502## Common Commands503504```bash505cd apps/api506pnpm dev # Start dev server (watch mode)507pnpm test # Unit tests (23 suites)508pnpm test:e2e # E2E tests509pnpm test:cov # Coverage report510pnpm dlx prisma migrate dev # Create migration511pnpm dlx prisma generate # Generate Prisma client512pnpm dlx prisma studio # Open Prisma Studio GUI513pnpm run db:seed # Seed database514pnpm lint # ESLint515```516517---518519## Notable Patterns5205211. **Prisma 7 Driver Adapter**: Uses `@prisma/adapter-pg` with raw `pg.Pool` instead of binary engine5222. **Vietnamese messages**: All user-facing error messages in Vietnamese5233. **CSV export with BOM**: `toCsv()` helper prepends `\uFEFF` for Excel Vietnamese support5244. **Structured error codes**: `AUTH_EMAIL_EXISTS`, `AUTH_OTP_INVALID`, etc. for deterministic client handling5255. **Device-aware sessions**: Refresh tokens track userAgent, ipAddress, deviceName, lastUsedAt5266. **OAuth account linking**: Google/Facebook auto-link to existing email; users can set password post-OAuth5277. **WebSocket deps present**: `@nestjs/websockets` + `socket.io` in deps but no gateway implemented yet5288. **Regulation-driven rules**: Core limits (age, roster, foreign players, goal time) configurable per-season5299. **RegulationHelper fallback**: DB value → defaults → hardcoded fallback53010. **Standings computed live**: From match events, not from Standing model directly531532---533> Converted and distributed by [TomeVault](https://tomevault.io/claim/daithang-organization) — claim your Tome and manage your conversions.534<!-- tomevault:4.0:skill_md:2026-04-13 -->