chore: Новый черновик DRAFT, удалить старые docs-v

This commit is contained in:
2026-07-30 09:19:59 +03:00
parent 6f6e4896af
commit 590fb63ca7
122 changed files with 29145 additions and 3253 deletions

View File

@@ -0,0 +1,10 @@
SIMPLE_PORT=3001
COMPLEX_PORT=3002
JWT_ACCESS_SECRET=demo-access-secret-change-me
JWT_REFRESH_SECRET=demo-refresh-secret-change-me
JWT_ACCESS_TTL=60s
JWT_REFRESH_TTL=7d
COOKIE_SESSION_TTL_MS=1800000
COOKIE_SECURE=false
MOCK_SLOW_DELAY_MS=1500
MOCK_TIMEOUT_DELAY_MS=30000

6
examples/demo-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
.env
npm-debug.log*
*.tsbuildinfo

View File

@@ -0,0 +1,117 @@
# Demo backend
An isolated NestJS package containing two independently launched HTTP applications and two OpenAPI contracts. Data is deterministic and stored in memory; no database or external service is required.
## Applications
| Application | Port | Authentication | Swagger UI | OpenAPI JSON |
| ----------- | -----: | ------------------------------ | ---------------------------- | ------------------------------------ |
| Simple | `3001` | JWT access/refresh | `http://localhost:3001/docs` | `http://localhost:3001/openapi.json` |
| Complex | `3002` | HttpOnly cookie session + CSRF | `http://localhost:3002/docs` | `http://localhost:3002/openapi.json` |
Committed specifications are generated at `openapi/simple.json` and `openapi/complex.json`.
## Requirements
- Node.js 20 or newer
- npm 10 or newer
## Start
```bash
npm install
npm run start:dev
```
Run only one application:
```bash
npm run dev:simple
npm run dev:complex
```
Production-style build and start:
```bash
npm run build
npm run start:simple
npm run start:complex
```
## Demo users
All passwords are `demo1234`.
### Simple API
| Email | Role |
| --------------------- | -------- |
| `admin@demo.local` | admin |
| `customer@demo.local` | customer |
### Complex API
| Email | Role | Organizations |
| ---------------------- | ------- | ------------------------ |
| `admin@complex.demo` | admin | `org-acme`, `org-globex` |
| `manager@complex.demo` | manager | `org-acme` |
| `support@complex.demo` | support | `org-acme` |
| `viewer@complex.demo` | viewer | `org-acme` |
## Simple authentication example
```bash
curl -s http://localhost:3001/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@demo.local","password":"demo1234"}'
```
Use `data.tokens.accessToken` as a Bearer token.
## Complex authentication example
```bash
curl -i -c /tmp/demo-cookies.txt http://localhost:3002/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@complex.demo","password":"demo1234"}'
```
The response body contains `data.csrfToken`. Protected reads require cookies and tenant context:
```bash
curl -b /tmp/demo-cookies.txt http://localhost:3002/api/v1/products \
-H 'X-Organization-Id: org-acme'
```
Mutations also require `X-CSRF-Token` with the value returned by login.
## OpenAPI
Generate and validate both specifications:
```bash
npm run openapi:generate
npm run openapi:validate
```
The validator checks OpenAPI validity, unique operation IDs, expected security schemes and route isolation between the two applications.
## Verification
```bash
npm run typecheck
npm run build
npm run test:e2e
npm run openapi:generate
npm run openapi:validate
```
## Built-in frontend cases
The intentionally supported cases are documented in [docs/CASES.md](./docs/CASES.md). They include controlled latency and errors, JWT refresh races, cookie expiration, CSRF, RBAC, tenant switching, offset and cursor pagination, ETag, optimistic locking, idempotency, background jobs, multipart files, polymorphic DTOs, audit events and realtime reconnection/deduplication.
The Socket.IO event contract is documented separately in [docs/WEBSOCKET.md](./docs/WEBSOCKET.md).
## Important limitation
This is a frontend architecture fixture, not a production identity or commerce service. Passwords, sessions, files and mutations live only in process memory. Restart or `POST /api/v1/testing/reset` restores deterministic data.

View File

@@ -0,0 +1,133 @@
# Frontend architecture cases
This document is the contract for behaviors intentionally built into the demo backend. The APIs are deterministic: reset state before a demo with `POST /api/v1/testing/reset`.
## Project-size mapping
| Frontend type | Recommended API surface |
| -------------------- | ------------------------------------------------------------------------------------ |
| Landing or small SPA | Public Simple products and categories |
| Medium application | Full Simple API with JWT, user profile and orders |
| Large application | Complex API with cookie session, tenant context, RBAC, jobs, files and realtime chat |
## Controlled network scenarios
Send `X-Demo-Scenario` with any HTTP request. The response repeats the selected value in `X-Demo-Scenario`.
| Value | Behavior | Frontend concern |
| --------------- | --------------------------------------------- | --------------------------------------- |
| `normal` | Normal deterministic response | Happy path |
| `slow` | Delays for `MOCK_SLOW_DELAY_MS` | Loading states, cancellation, skeletons |
| `timeout` | Delays for `MOCK_TIMEOUT_DELAY_MS` | Client timeout and abort handling |
| `server-error` | Returns `500` | Error boundaries and retry UX |
| `rate-limited` | Returns `429` and `Retry-After: 3` | Backoff and retry policy |
| `empty` | Changes a list response to a valid empty page | Empty states |
| `expired-auth` | Protected endpoint returns `401` | Refresh or re-login flow |
| `forbidden` | Protected endpoint returns `403` | Permission UI |
| `conflict` | Mutation returns `409` | Conflict UX and rollback |
| `large-dataset` | Expands a list response to 250 items | Rendering and virtualization |
Scenario effects are request-local. They do not introduce random failures or make automated tests flaky.
## Authentication cases
### Simple JWT
- Access and refresh tokens are returned by `POST /api/v1/auth/login`.
- Access tokens are sent as `Authorization: Bearer <token>`.
- Refresh tokens rotate. Reusing an already rotated refresh token returns `401 REFRESH_TOKEN_REUSED`.
- Logout revokes the supplied refresh token and is idempotent.
- Access and refresh lifetimes are configurable through environment variables.
- Protected requests support forced `401` and `403` scenarios.
### Complex cookie session
- Login sets HttpOnly `demo_session` and readable `demo_csrf` cookies.
- Browser requests must use `credentials: 'include'`.
- Mutations additionally send the `demo_csrf` value in `X-CSRF-Token`.
- Session refresh rotates both the session ID and CSRF token.
- `POST /api/v1/testing/session/expire` expires the current session without changing frontend state.
- Roles can change while the session remains active.
- Socket.IO authenticates using the same `demo_session` cookie.
## Data fetching cases
| Case | Endpoint example |
| -------------------- | ------------------------------------------------------ |
| Offset pagination | Simple products, customers, audit events |
| Cursor pagination | Complex products, orders, notifications, chat messages |
| Search and filtering | Products and customers |
| Sorting | Simple products |
| Nested resources | Organization members and conversation messages |
| Nullable values | Avatar, category parent, publish date, file/job result |
| Decimal strings | Complex prices, discounts and totals |
| Polymorphic union | Order, inventory and system notification payloads |
| Tree data | Complex categories with parent/child IDs |
| Large seed | `POST /api/v1/testing/seed/large` |
## Cache case
Product details return a weak `ETag`. Repeat the request with `If-None-Match`; unchanged data returns `304 Not Modified`. Product updates increment `version` and produce a new ETag.
## Mutation and concurrency cases
- Simple and Complex product updates require the last-read `version`.
- Inventory adjustments require `version` and reject negative stock.
- Stale writes return `409` with a stable error code.
- Complex order creation requires `Idempotency-Key`.
- Repeating the same order request and key returns the original order instead of creating a duplicate.
- Invalid nested forms return field-oriented validation details.
- Order cancellation validates allowed state transitions.
- Mutations are added to the Complex audit log.
## RBAC and multitenancy cases
- Complex domain routes require `X-Organization-Id`.
- Missing tenant context returns `400 ORGANIZATION_REQUIRED`.
- A tenant unavailable to the current user returns `403 ORGANIZATION_FORBIDDEN`.
- Admin and manager can mutate catalog/inventory and start exports.
- Support can create and cancel orders but cannot change products.
- Viewer is read-only.
- Test users and memberships are documented in the main README.
## Background work
`POST /api/v1/exports/orders` returns `202` and a job. Poll `GET /api/v1/jobs/:id` to observe:
```text
pending -> processing -> completed
```
Progress and `resultUrl` change over time. Downloading the result before completion returns `409 JOB_NOT_COMPLETED`.
## Files
- Multipart upload accepts a `file` field up to 5 MiB.
- Metadata and bytes remain in memory until reset or restart.
- Download returns binary content and `Content-Disposition`.
- The API supports missing-file validation, loading progress and cancellation testing.
## Realtime chat
- REST supplies conversation and cursor-paginated message history.
- Socket.IO supplies joins, typing state and messages.
- `clientMessageId` deduplicates retried sends.
- Invalid or expired cookie sessions are disconnected.
- REST-created messages are also broadcast to the Socket.IO room.
- The complete event contract is in [WEBSOCKET.md](./WEBSOCKET.md).
## Observability and errors
Every HTTP response exposes `X-Request-Id`, `X-Response-Time` and `X-Demo-Scenario`. A frontend can supply its own `X-Request-Id`. Error bodies include that request ID, a stable machine-readable `code`, human-readable `message`, field `details`, timestamp and path.
## Reset behavior
| Endpoint | Effect |
| ------------------------------------- | ------------------------------------- |
| `POST /api/v1/testing/reset` | Restores the small deterministic seed |
| `POST /api/v1/testing/seed/small` | Loads normal fixtures |
| `POST /api/v1/testing/seed/large` | Loads 250 products and customers |
| `POST /api/v1/testing/users/:id/role` | Changes access without a new login |
| `POST /api/v1/testing/session/expire` | Expires the current Complex session |
All mutations are process-local and disappear after restart.

View File

@@ -0,0 +1,79 @@
# Socket.IO chat contract
## Connection
Connect to the Complex API namespace:
```text
http://localhost:3002/chat
```
The browser must already have a valid `demo_session` cookie and use credentials. Both `websocket` and `polling` transports are enabled.
```ts
import { io } from "socket.io-client";
const socket = io("http://localhost:3002/chat", {
withCredentials: true,
});
```
An invalid session receives `chat:error` and is disconnected.
## Client events
### `chat:join`
```json
{
"organizationId": "org-acme",
"conversationId": "conversation-support"
}
```
Response event: `chat:joined`.
### `chat:leave`
Uses the same payload. Response event: `chat:left`.
### `message:send`
```json
{
"organizationId": "org-acme",
"conversationId": "conversation-support",
"text": "Can you check this order?",
"clientMessageId": "frontend-generated-uuid"
}
```
Response event: `message:ack`. Room broadcast: `message:created`.
Sending the same `clientMessageId` again returns the existing message. Frontends should also deduplicate incoming `message:created` by server message `id`.
### `typing:start` and `typing:stop`
Use the `chat:join` payload. Other room members receive `typing:started` or `typing:stopped`:
```json
{
"conversationId": "conversation-support",
"userId": "complex-user-support"
}
```
## Error event
`chat:error` always contains a stable shape:
```json
{
"code": "CHAT_OPERATION_FAILED",
"message": "Conversation not found."
}
```
## Reconnect expectations
After reconnect, the frontend should join active conversations again and reload messages after its last known cursor. A session expired by `POST /api/v1/testing/session/expire` rejects the next Socket.IO connection.

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

8654
examples/demo-backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
{
"name": "demo-backend",
"version": "1.0.0",
"private": true,
"description": "Two isolated NestJS demo APIs for testing frontend application architecture",
"license": "UNLICENSED",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start:dev": "concurrently -k -n simple,complex -c blue,magenta \"npm:dev:simple\" \"npm:dev:complex\"",
"dev:simple": "nest start --watch --entryFile apps/simple/main",
"dev:complex": "nest start --watch --entryFile apps/complex/main",
"start:simple": "node dist/apps/simple/main.js",
"start:complex": "node dist/apps/complex/main.js",
"openapi:generate": "npm run build && node dist/scripts/generate-openapi.js",
"openapi:validate": "node dist/scripts/validate-openapi.js",
"test": "jest",
"test:e2e": "jest --runInBand",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"*.{json,md}\" \"docs/**/*.md\""
},
"dependencies": {
"@nestjs/common": "^11.1.6",
"@nestjs/core": "^11.1.6",
"@nestjs/jwt": "^11.0.1",
"@nestjs/platform-express": "^11.1.6",
"@nestjs/platform-socket.io": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"@nestjs/websockets": "^11.1.6",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"socket.io": "^4.8.1"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^12.0.0",
"@nestjs/cli": "^11.0.10",
"@nestjs/testing": "^11.1.6",
"@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.3",
"@types/jest": "^29.5.14",
"@types/multer": "^2.0.0",
"@types/node": "^24.0.15",
"@types/supertest": "^6.0.3",
"concurrently": "^9.2.0",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"socket.io-client": "^4.8.1",
"supertest": "^7.1.4",
"ts-jest": "^29.4.0",
"typescript": "^5.9.2"
},
"overrides": {
"@nestjs/swagger": {
"js-yaml": "5.2.2"
}
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": ".",
"testRegex": ".*\\.e2e-spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": "tsconfig.json"
}
]
},
"collectCoverageFrom": [
"src/**/*.(t|j)s"
],
"coverageDirectory": "coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,20 @@
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import type { OpenAPIObject } from "@nestjs/swagger";
import { configureApplication } from "../../common/configure-application";
import { createOpenApiDocument, mountOpenApi } from "../../common/openapi";
import { ComplexAppModule } from "./complex.module";
export async function createComplexApplication(
logger: false | undefined = undefined,
): Promise<{ app: NestExpressApplication; document: OpenAPIObject }> {
const app = await NestFactory.create<NestExpressApplication>(
ComplexAppModule,
{ logger },
);
configureApplication(app);
const port = Number(process.env.COMPLEX_PORT ?? 3002);
const document = createOpenApiDocument(app, { kind: "complex", port });
mountOpenApi(app, document, "Demo Complex API");
return { app, document };
}

View File

@@ -0,0 +1,175 @@
import { parse as parseCookie } from "cookie";
import { Logger } from "@nestjs/common";
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from "@nestjs/websockets";
import type { Server, Socket } from "socket.io";
import { ComplexSessionService, SESSION_COOKIE } from "./complex.auth";
import { ComplexStore } from "./complex.store";
import type { ChatMessageDto, SendMessageDto } from "./dto/chat.dto";
interface ChatSocketData {
userId: string;
organizationIds: string[];
}
interface ChatContextPayload {
organizationId: string;
conversationId: string;
}
interface SocketMessagePayload extends ChatContextPayload, SendMessageDto {}
@WebSocketGateway({
namespace: "/chat",
cors: { origin: true, credentials: true },
transports: ["websocket", "polling"],
})
export class ChatGateway implements OnGatewayConnection {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(ChatGateway.name);
constructor(
private readonly sessions: ComplexSessionService,
private readonly store: ComplexStore,
) {}
handleConnection(client: Socket): void {
const cookies = parseCookie(client.handshake.headers.cookie ?? "");
const session = this.sessions.get(cookies[SESSION_COOKIE]);
const user = session ? this.store.findUserById(session.userId) : undefined;
if (!session || !user) {
client.emit("chat:error", {
code: "SESSION_EXPIRED",
message: "Valid demo_session cookie is required.",
});
client.disconnect(true);
return;
}
client.data = {
userId: user.id,
organizationIds: user.organizationIds,
} satisfies ChatSocketData;
this.logger.debug(`Socket ${client.id} connected as ${user.id}`);
}
@SubscribeMessage("chat:join")
join(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ChatContextPayload,
) {
try {
const data = client.data as ChatSocketData;
this.assertOrganization(data, payload.organizationId);
this.store.getConversation(
payload.organizationId,
data.userId,
payload.conversationId,
);
void client.join(this.room(payload.conversationId));
return {
event: "chat:joined",
data: { conversationId: payload.conversationId },
};
} catch (error) {
return { event: "chat:error", data: this.socketError(error) };
}
}
@SubscribeMessage("chat:leave")
leave(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ChatContextPayload,
) {
void client.leave(this.room(payload.conversationId));
return {
event: "chat:left",
data: { conversationId: payload.conversationId },
};
}
@SubscribeMessage("message:send")
send(
@ConnectedSocket() client: Socket,
@MessageBody() payload: SocketMessagePayload,
) {
try {
const data = client.data as ChatSocketData;
this.assertOrganization(data, payload.organizationId);
const message = this.store.sendMessage(
payload.organizationId,
data.userId,
payload.conversationId,
payload,
);
this.broadcastMessage(message);
return { event: "message:ack", data: message };
} catch (error) {
return { event: "chat:error", data: this.socketError(error) };
}
}
@SubscribeMessage("typing:start")
typingStart(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ChatContextPayload,
): void {
this.broadcastTyping(client, payload, true);
}
@SubscribeMessage("typing:stop")
typingStop(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ChatContextPayload,
): void {
this.broadcastTyping(client, payload, false);
}
broadcastMessage(message: ChatMessageDto): void {
this.server
.to(this.room(message.conversationId))
.emit("message:created", message);
}
private broadcastTyping(
client: Socket,
payload: ChatContextPayload,
active: boolean,
): void {
const data = client.data as ChatSocketData;
if (!data.organizationIds.includes(payload.organizationId)) return;
client
.to(this.room(payload.conversationId))
.emit(active ? "typing:started" : "typing:stopped", {
conversationId: payload.conversationId,
userId: data.userId,
});
}
private assertOrganization(
data: ChatSocketData,
organizationId: string,
): void {
if (!data.organizationIds.includes(organizationId))
throw new Error("Organization is unavailable to this user.");
}
private room(conversationId: string): string {
return `conversation:${conversationId}`;
}
private socketError(error: unknown): { code: string; message: string } {
return {
code: "CHAT_OPERATION_FAILED",
message:
error instanceof Error ? error.message : "Chat operation failed.",
};
}
}

View File

@@ -0,0 +1,261 @@
import { randomUUID } from "node:crypto";
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
UnauthorizedException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import type { CookieOptions, Request } from "express";
import type { DemoRequest } from "../../common/request.types";
import { assertNoForcedAuthFailure } from "../../common/scenario.interceptor";
import {
ComplexRole,
type ComplexLoginDto,
type ComplexUserDto,
type CookieSessionResponseDto,
} from "./dto/identity.dto";
import { ComplexStore } from "./complex.store";
export const SESSION_COOKIE = "demo_session";
export const CSRF_COOKIE = "demo_csrf";
const ROLES_METADATA = "complex-roles";
export interface ComplexDemoRequest extends DemoRequest {
sessionToken?: string;
}
interface SessionRecord {
token: string;
userId: string;
csrfToken: string;
expiresAt: number;
}
export interface CreatedSession {
token: string;
csrfToken: string;
expiresAt: string;
user: ComplexUserDto;
}
export const ComplexRoles = (...roles: ComplexRole[]) =>
SetMetadata(ROLES_METADATA, roles);
export function sessionCookieOptions(): CookieOptions {
return {
httpOnly: true,
sameSite: "lax",
secure: process.env.COOKIE_SECURE === "true",
maxAge: Number(process.env.COOKIE_SESSION_TTL_MS ?? 1_800_000),
path: "/",
};
}
export function csrfCookieOptions(): CookieOptions {
return {
httpOnly: false,
sameSite: "lax",
secure: process.env.COOKIE_SECURE === "true",
maxAge: Number(process.env.COOKIE_SESSION_TTL_MS ?? 1_800_000),
path: "/",
};
}
@Injectable()
export class ComplexSessionService {
private readonly sessions = new Map<string, SessionRecord>();
constructor(private readonly store: ComplexStore) {}
login(dto: ComplexLoginDto): CreatedSession {
const user = this.store.findUserByEmail(dto.email);
if (!user || user.password !== dto.password) {
throw new UnauthorizedException({
code: "INVALID_CREDENTIALS",
message: "Email or password is incorrect.",
});
}
return this.create(user.id);
}
get(token: string | undefined): SessionRecord | undefined {
if (!token) return undefined;
const session = this.sessions.get(token);
if (!session) return undefined;
if (session.expiresAt <= Date.now()) {
this.sessions.delete(token);
return undefined;
}
return session;
}
rotate(token: string): CreatedSession {
const existing = this.get(token);
if (!existing)
throw new UnauthorizedException({
code: "SESSION_EXPIRED",
message: "Cookie session is missing or expired.",
});
this.sessions.delete(token);
return this.create(existing.userId);
}
revoke(token: string | undefined): void {
if (token) this.sessions.delete(token);
}
expire(token: string | undefined): void {
const session = token ? this.sessions.get(token) : undefined;
if (session) session.expiresAt = 0;
}
reset(): void {
this.sessions.clear();
}
response(session: CreatedSession): CookieSessionResponseDto {
return {
data: {
user: session.user,
csrfToken: session.csrfToken,
expiresAt: session.expiresAt,
},
};
}
private create(userId: string): CreatedSession {
const user = this.store.findUserById(userId);
if (!user)
throw new UnauthorizedException({
code: "USER_NOT_FOUND",
message: "Session user no longer exists.",
});
const ttl = Number(process.env.COOKIE_SESSION_TTL_MS ?? 1_800_000);
const record: SessionRecord = {
token: randomUUID(),
userId,
csrfToken: randomUUID(),
expiresAt: Date.now() + ttl,
};
this.sessions.set(record.token, record);
return {
token: record.token,
csrfToken: record.csrfToken,
expiresAt: new Date(record.expiresAt).toISOString(),
user: this.store.publicUser(user),
};
}
}
@Injectable()
export class ComplexCookieGuard implements CanActivate {
constructor(
private readonly sessions: ComplexSessionService,
private readonly store: ComplexStore,
) {}
canActivate(context: ExecutionContext): boolean {
const request = context
.switchToHttp()
.getRequest<Request>() as ComplexDemoRequest;
assertNoForcedAuthFailure(request);
const cookies = request.cookies as Record<string, string> | undefined;
const token = cookies?.[SESSION_COOKIE];
const session = this.sessions.get(token);
if (!session)
throw new UnauthorizedException({
code: "SESSION_EXPIRED",
message: "Cookie session is missing or expired.",
});
const user = this.store.findUserById(session.userId);
if (!user)
throw new UnauthorizedException({
code: "USER_NOT_FOUND",
message: "Session user no longer exists.",
});
request.user = this.store.publicUser(user);
request.sessionToken = token;
return true;
}
}
@Injectable()
export class ComplexCsrfGuard implements CanActivate {
constructor(private readonly sessions: ComplexSessionService) {}
canActivate(context: ExecutionContext): boolean {
const request = context
.switchToHttp()
.getRequest<Request>() as ComplexDemoRequest;
const session = this.sessions.get(request.sessionToken);
const headerToken = request.headers["x-csrf-token"];
const cookieToken = (
request.cookies as Record<string, string> | undefined
)?.[CSRF_COOKIE];
if (
!session ||
typeof headerToken !== "string" ||
headerToken !== session.csrfToken ||
cookieToken !== session.csrfToken
) {
throw new ForbiddenException({
code: "CSRF_INVALID",
message:
"A matching X-CSRF-Token header and demo_csrf cookie are required.",
});
}
return true;
}
}
@Injectable()
export class ComplexOrganizationGuard implements CanActivate {
constructor(private readonly store: ComplexStore) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<ComplexDemoRequest>();
const organizationId = request.headers["x-organization-id"];
if (typeof organizationId !== "string" || organizationId.length === 0) {
throw new BadRequestException({
code: "ORGANIZATION_REQUIRED",
message: "X-Organization-Id header is required.",
});
}
if (
!request.user ||
!this.store.isMember(request.user.id, organizationId)
) {
throw new ForbiddenException({
code: "ORGANIZATION_FORBIDDEN",
message: "The user is not an active member of this organization.",
});
}
request.organizationId = organizationId;
return true;
}
}
@Injectable()
export class ComplexRolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<ComplexRole[]>(
ROLES_METADATA,
[context.getHandler(), context.getClass()],
);
if (!required?.length) return true;
const request = context.switchToHttp().getRequest<ComplexDemoRequest>();
if (!request.user || !required.includes(request.user.role as ComplexRole)) {
throw new ForbiddenException({
code: "ROLE_FORBIDDEN",
message: `One of these roles is required: ${required.join(", ")}.`,
});
}
return true;
}
}

View File

@@ -0,0 +1,71 @@
import { Module } from "@nestjs/common";
import { InfrastructureModule } from "../../common/infrastructure.module";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
ComplexOrganizationGuard,
ComplexRolesGuard,
ComplexSessionService,
} from "./complex.auth";
import { ComplexStore } from "./complex.store";
import { ChatGateway } from "./chat.gateway";
import { ChatController } from "./controllers/chat.controller";
import {
ComplexCatalogController,
ComplexInventoryController,
ComplexProductsController,
} from "./controllers/catalog.controllers";
import {
ComplexOrdersController,
CustomersController,
PaymentsController,
ReviewsController,
} from "./controllers/commerce.controllers";
import {
ComplexAuthController,
ComplexUsersController,
OrganizationsController,
} from "./controllers/identity.controllers";
import {
AuditController,
FilesController,
JobsController,
NotificationsController,
} from "./controllers/operations.controllers";
import {
ComplexHealthController,
ComplexTestingController,
} from "./controllers/system.controllers";
@Module({
imports: [InfrastructureModule],
controllers: [
ComplexHealthController,
ComplexAuthController,
ComplexUsersController,
OrganizationsController,
ComplexProductsController,
ComplexCatalogController,
ComplexInventoryController,
CustomersController,
ComplexOrdersController,
PaymentsController,
ReviewsController,
NotificationsController,
FilesController,
AuditController,
JobsController,
ChatController,
ComplexTestingController,
],
providers: [
ComplexStore,
ComplexSessionService,
ComplexCookieGuard,
ComplexCsrfGuard,
ComplexOrganizationGuard,
ComplexRolesGuard,
ChatGateway,
],
})
export class ComplexAppModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,217 @@
import {
Body,
Controller,
Get,
Headers,
Param,
Patch,
Post,
Query,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import {
ApiCookieAuth,
ApiCreatedResponse,
ApiNotModifiedResponse,
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import {
ApiDemoScenarioHeader,
ApiOrganizationHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
type ComplexDemoRequest,
ComplexOrganizationGuard,
ComplexRoles,
ComplexRolesGuard,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import {
AdjustInventoryDto,
BrandsResponseDto,
ComplexCategoriesResponseDto,
ComplexProductResponseDto,
ComplexProductsResponseDto,
CreateComplexProductDto,
CursorProductQueryDto,
InventoryItemResponseDto,
InventoryResponseDto,
UpdateComplexProductDto,
WarehousesResponseDto,
} from "../dto/catalog.dto";
import { ComplexRole } from "../dto/identity.dto";
@ApiTags("Products")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("products")
export class ComplexProductsController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({ summary: "List tenant products using cursor pagination" })
@ApiOkResponse({ type: ComplexProductsResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query() query: CursorProductQueryDto,
): ComplexProductsResponseDto {
return this.store.listProducts(request.organizationId!, query);
}
@Get(":id")
@ApiOperation({
summary: "Get a rich product with variants and ETag support",
})
@ApiOkResponse({ type: ComplexProductResponseDto })
@ApiNotModifiedResponse()
@ApiStandardErrors({ auth: true, notFound: true })
get(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Headers("if-none-match") ifNoneMatch: string | undefined,
@Res({ passthrough: true }) response: Response,
): ComplexProductResponseDto | undefined {
const product = this.store.getProduct(request.organizationId!, id);
const etag = `W/\"${product.id}-v${product.version}\"`;
response.setHeader("ETag", etag);
response.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
if (etag === ifNoneMatch) {
response.status(304);
return undefined;
}
return { data: product };
}
@Post()
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Create a tenant product with variants" })
@ApiCreatedResponse({ type: ComplexProductResponseDto })
@ApiStandardErrors({ auth: true })
create(
@Req() request: ComplexDemoRequest,
@Body() dto: CreateComplexProductDto,
): ComplexProductResponseDto {
return {
data: this.store.createProduct(
request.organizationId!,
request.user!.id,
dto,
),
};
}
@Patch(":id")
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Update a product using optimistic locking" })
@ApiOkResponse({ type: ComplexProductResponseDto })
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
update(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Body() dto: UpdateComplexProductDto,
): ComplexProductResponseDto {
return {
data: this.store.updateProduct(
request.organizationId!,
request.user!.id,
id,
dto,
),
};
}
}
@ApiTags("Catalog")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller()
export class ComplexCatalogController {
constructor(private readonly store: ComplexStore) {}
@Get("categories")
@ApiOperation({ summary: "List a recursive category tree" })
@ApiOkResponse({ type: ComplexCategoriesResponseDto })
@ApiStandardErrors({ auth: true })
categories(@Req() request: ComplexDemoRequest): ComplexCategoriesResponseDto {
return { data: this.store.listCategories(request.organizationId!) };
}
@Get("brands")
@ApiOperation({ summary: "List product brands" })
@ApiOkResponse({ type: BrandsResponseDto })
@ApiStandardErrors({ auth: true })
brands(@Req() request: ComplexDemoRequest): BrandsResponseDto {
return { data: this.store.listBrands(request.organizationId!) };
}
}
@ApiTags("Inventory")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller()
export class ComplexInventoryController {
constructor(private readonly store: ComplexStore) {}
@Get("warehouses")
@ApiOperation({ summary: "List tenant warehouses" })
@ApiOkResponse({ type: WarehousesResponseDto })
@ApiStandardErrors({ auth: true })
warehouses(@Req() request: ComplexDemoRequest): WarehousesResponseDto {
return { data: this.store.listWarehouses(request.organizationId!) };
}
@Get("inventory")
@ApiOperation({ summary: "List inventory with optional product filter" })
@ApiOkResponse({ type: InventoryResponseDto })
@ApiStandardErrors({ auth: true })
inventory(
@Req() request: ComplexDemoRequest,
@Query("productId") productId?: string,
): InventoryResponseDto {
return {
data: this.store.listInventory(request.organizationId!, productId),
};
}
@Post("inventory/:id/adjust")
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Adjust stock using optimistic locking" })
@ApiOkResponse({ type: InventoryItemResponseDto })
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
adjust(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Body() dto: AdjustInventoryDto,
): InventoryItemResponseDto {
return {
data: this.store.adjustInventory(
request.organizationId!,
request.user!.id,
id,
dto,
),
};
}
}

View File

@@ -0,0 +1,125 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import {
ApiCookieAuth,
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import {
ApiDemoScenarioHeader,
ApiOrganizationHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import { ChatGateway } from "../chat.gateway";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
type ComplexDemoRequest,
ComplexOrganizationGuard,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import {
ConversationResponseDto,
ConversationsResponseDto,
MessageResponseDto,
MessagesResponseDto,
SendMessageDto,
} from "../dto/chat.dto";
import { CursorQueryDto } from "../dto/operations.dto";
@ApiTags("Chat")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("conversations")
export class ChatController {
constructor(
private readonly store: ComplexStore,
private readonly gateway: ChatGateway,
) {}
@Get()
@ApiOperation({ summary: "List conversations available to the current user" })
@ApiOkResponse({ type: ConversationsResponseDto })
@ApiStandardErrors({ auth: true })
list(@Req() request: ComplexDemoRequest): ConversationsResponseDto {
return {
data: this.store.listConversations(
request.organizationId!,
request.user!.id,
),
};
}
@Get(":id")
@ApiOperation({ summary: "Get one conversation" })
@ApiOkResponse({ type: ConversationResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
get(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): ConversationResponseDto {
return {
data: this.store.getConversation(
request.organizationId!,
request.user!.id,
id,
),
};
}
@Get(":id/messages")
@ApiOperation({ summary: "Load message history using cursor pagination" })
@ApiOkResponse({ type: MessagesResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
messages(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Query() query: CursorQueryDto,
): MessagesResponseDto {
return this.store.listMessages(
request.organizationId!,
request.user!.id,
id,
query.cursor,
query.limit,
);
}
@Post(":id/messages")
@HttpCode(200)
@UseGuards(ComplexCsrfGuard)
@ApiSecurity("csrf")
@ApiOperation({
summary:
"Send an idempotent message through REST and broadcast it over Socket.IO",
})
@ApiOkResponse({ type: MessageResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
send(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Body() dto: SendMessageDto,
): MessageResponseDto {
const message = this.store.sendMessage(
request.organizationId!,
request.user!.id,
id,
dto,
);
this.gateway.broadcastMessage(message);
return { data: message };
}
}

View File

@@ -0,0 +1,227 @@
import {
BadRequestException,
Body,
Controller,
Get,
Headers,
HttpCode,
Param,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import {
ApiCookieAuth,
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import {
ApiDemoScenarioHeader,
ApiIdempotencyHeader,
ApiOrganizationHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
type ComplexDemoRequest,
ComplexOrganizationGuard,
ComplexRoles,
ComplexRolesGuard,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import {
ComplexOrderResponseDto,
ComplexOrdersResponseDto,
CreateComplexOrderDto,
CreateReviewDto,
CustomerQueryDto,
CustomerResponseDto,
CustomersResponseDto,
OrderCursorQueryDto,
PaymentsResponseDto,
PromotionsResponseDto,
ReviewResponseDto,
ReviewsResponseDto,
} from "../dto/commerce.dto";
import { ComplexRole } from "../dto/identity.dto";
@ApiTags("Customers")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("customers")
export class CustomersController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({
summary: "List customers using offset pagination and search",
})
@ApiOkResponse({ type: CustomersResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query() query: CustomerQueryDto,
): CustomersResponseDto {
return this.store.listCustomers(request.organizationId!, query);
}
@Get(":id")
@ApiOperation({ summary: "Get one customer with nested address" })
@ApiOkResponse({ type: CustomerResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
get(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): CustomerResponseDto {
return { data: this.store.getCustomer(request.organizationId!, id) };
}
}
@ApiTags("Orders")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("orders")
export class ComplexOrdersController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({ summary: "List tenant orders using cursor pagination" })
@ApiOkResponse({ type: ComplexOrdersResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query() query: OrderCursorQueryDto,
): ComplexOrdersResponseDto {
return this.store.listOrders(request.organizationId!, query);
}
@Get(":id")
@ApiOperation({ summary: "Get one order and its state" })
@ApiOkResponse({ type: ComplexOrderResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
get(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): ComplexOrderResponseDto {
return { data: this.store.getOrder(request.organizationId!, id) };
}
@Post()
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager, ComplexRole.Support)
@ApiSecurity("csrf")
@ApiIdempotencyHeader()
@ApiOperation({ summary: "Create an order idempotently" })
@ApiCreatedResponse({ type: ComplexOrderResponseDto })
@ApiStandardErrors({ auth: true, conflict: true })
create(
@Req() request: ComplexDemoRequest,
@Headers("idempotency-key") idempotencyKey: string,
@Body() dto: CreateComplexOrderDto,
): ComplexOrderResponseDto {
if (!idempotencyKey) {
throw new BadRequestException({
code: "IDEMPOTENCY_KEY_REQUIRED",
message: "Idempotency-Key header is required.",
});
}
return {
data: this.store.createOrder(
request.organizationId!,
request.user!.id,
idempotencyKey,
dto,
),
};
}
@Post(":id/cancel")
@HttpCode(200)
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager, ComplexRole.Support)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Apply a validated order state transition" })
@ApiOkResponse({ type: ComplexOrderResponseDto })
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
cancel(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): ComplexOrderResponseDto {
return {
data: this.store.cancelOrder(
request.organizationId!,
request.user!.id,
id,
),
};
}
}
@ApiTags("Payments and promotions")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller()
export class PaymentsController {
constructor(private readonly store: ComplexStore) {}
@Get("payments")
@ApiOperation({ summary: "List payments for tenant orders" })
@ApiOkResponse({ type: PaymentsResponseDto })
@ApiStandardErrors({ auth: true })
payments(@Req() request: ComplexDemoRequest): PaymentsResponseDto {
return { data: this.store.listPayments(request.organizationId!) };
}
@Get("promotions")
@ApiOperation({ summary: "List active and expired promotion contracts" })
@ApiOkResponse({ type: PromotionsResponseDto })
@ApiStandardErrors({ auth: true })
promotions(): PromotionsResponseDto {
return { data: this.store.listPromotions() };
}
}
@ApiTags("Reviews")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("reviews")
export class ReviewsController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({ summary: "List reviews with moderation state" })
@ApiOkResponse({ type: ReviewsResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query("productId") productId?: string,
): ReviewsResponseDto {
return { data: this.store.listReviews(request.organizationId!, productId) };
}
@Post()
@UseGuards(ComplexCsrfGuard)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Create a review in pending moderation state" })
@ApiCreatedResponse({ type: ReviewResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
create(
@Req() request: ComplexDemoRequest,
@Body() dto: CreateReviewDto,
): ReviewResponseDto {
return { data: this.store.createReview(request.organizationId!, dto) };
}
}

View File

@@ -0,0 +1,196 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import {
ApiCookieAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import {
ApiDemoScenarioHeader,
ApiOrganizationHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import type { ComplexDemoRequest } from "../complex.auth";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
ComplexOrganizationGuard,
ComplexRoles,
ComplexRolesGuard,
ComplexSessionService,
CSRF_COOKIE,
csrfCookieOptions,
SESSION_COOKIE,
sessionCookieOptions,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import {
ComplexLoginDto,
ComplexRole,
ComplexUsersResponseDto,
ComplexUserResponseDto,
CookieSessionResponseDto,
MembersResponseDto,
OrganizationResponseDto,
OrganizationsResponseDto,
} from "../dto/identity.dto";
@ApiTags("Auth")
@ApiDemoScenarioHeader()
@Controller("auth")
export class ComplexAuthController {
constructor(private readonly sessions: ComplexSessionService) {}
@Post("login")
@HttpCode(200)
@ApiOperation({ summary: "Login and establish HttpOnly cookie session" })
@ApiOkResponse({
type: CookieSessionResponseDto,
headers: {
"Set-Cookie": {
description: "Sets demo_session (HttpOnly) and demo_csrf cookies.",
schema: { type: "string" },
},
},
})
@ApiStandardErrors()
login(
@Body() dto: ComplexLoginDto,
@Res({ passthrough: true }) response: Response,
): CookieSessionResponseDto {
const session = this.sessions.login(dto);
this.setCookies(response, session.token, session.csrfToken);
return this.sessions.response(session);
}
@Post("refresh")
@HttpCode(200)
@UseGuards(ComplexCookieGuard, ComplexCsrfGuard)
@ApiCookieAuth("cookieSession")
@ApiSecurity("csrf")
@ApiOperation({ summary: "Rotate the current cookie session and CSRF token" })
@ApiOkResponse({ type: CookieSessionResponseDto })
@ApiStandardErrors({ auth: true })
refresh(
@Req() request: ComplexDemoRequest,
@Res({ passthrough: true }) response: Response,
): CookieSessionResponseDto {
const session = this.sessions.rotate(request.sessionToken!);
this.setCookies(response, session.token, session.csrfToken);
return this.sessions.response(session);
}
@Post("logout")
@HttpCode(204)
@UseGuards(ComplexCookieGuard, ComplexCsrfGuard)
@ApiCookieAuth("cookieSession")
@ApiSecurity("csrf")
@ApiOperation({
summary: "Revoke cookie session and clear authentication cookies",
})
@ApiNoContentResponse()
@ApiStandardErrors({ auth: true })
logout(
@Req() request: ComplexDemoRequest,
@Res({ passthrough: true }) response: Response,
): void {
this.sessions.revoke(request.sessionToken);
response.clearCookie(SESSION_COOKIE, { path: "/" });
response.clearCookie(CSRF_COOKIE, { path: "/" });
}
private setCookies(
response: Response,
token: string,
csrfToken: string,
): void {
response.cookie(SESSION_COOKIE, token, sessionCookieOptions());
response.cookie(CSRF_COOKIE, csrfToken, csrfCookieOptions());
}
}
@ApiTags("Users")
@ApiCookieAuth("cookieSession")
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard)
@Controller("users")
export class ComplexUsersController {
constructor(private readonly store: ComplexStore) {}
@Get("me")
@ApiOperation({
summary: "Get the authenticated user and available organizations",
})
@ApiOkResponse({ type: ComplexUserResponseDto })
@ApiStandardErrors({ auth: true })
me(@Req() request: ComplexDemoRequest): ComplexUserResponseDto {
const user = this.store.findUserById(request.user!.id)!;
return { data: this.store.publicUser(user) };
}
@Get()
@ApiOrganizationHeader()
@UseGuards(ComplexOrganizationGuard, ComplexRolesGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiOperation({ summary: "List users in the current tenant" })
@ApiOkResponse({ type: ComplexUsersResponseDto })
@ApiStandardErrors({ auth: true })
list(@Req() request: ComplexDemoRequest): ComplexUsersResponseDto {
return { data: this.store.listUsers(request.organizationId!) };
}
}
@ApiTags("Organizations")
@ApiCookieAuth("cookieSession")
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard)
@Controller("organizations")
export class OrganizationsController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({ summary: "List organizations available to the current user" })
@ApiOkResponse({ type: OrganizationsResponseDto })
@ApiStandardErrors({ auth: true })
list(@Req() request: ComplexDemoRequest): OrganizationsResponseDto {
return { data: this.store.listOrganizations(request.user!.id) };
}
@Get(":id")
@ApiOperation({ summary: "Get one available organization" })
@ApiOkResponse({ type: OrganizationResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
get(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): OrganizationResponseDto {
return { data: this.store.getOrganization(id, request.user!.id) };
}
@Get(":id/members")
@UseGuards(ComplexRolesGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiOperation({ summary: "List organization members and their roles" })
@ApiOkResponse({ type: MembersResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
members(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): MembersResponseDto {
this.store.getOrganization(id, request.user!.id);
return { data: this.store.listMembers(id) };
}
}

View File

@@ -0,0 +1,254 @@
import {
BadRequestException,
Controller,
Get,
HttpCode,
Param,
Post,
Query,
Req,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiAcceptedResponse,
ApiBody,
ApiConsumes,
ApiCookieAuth,
ApiCreatedResponse,
ApiExtraModels,
ApiOkResponse,
ApiOperation,
ApiProduces,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import {
ApiBinaryResponse,
ApiDemoScenarioHeader,
ApiOrganizationHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
type ComplexDemoRequest,
ComplexOrganizationGuard,
ComplexRoles,
ComplexRolesGuard,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import { ComplexRole } from "../dto/identity.dto";
import {
AuditEventsResponseDto,
AuditQueryDto,
CursorQueryDto,
FileResponseDto,
FilesResponseDto,
InventoryNotificationPayloadDto,
JobResponseDto,
NotificationResponseDto,
NotificationsResponseDto,
OrderNotificationPayloadDto,
SystemNotificationPayloadDto,
} from "../dto/operations.dto";
@ApiTags("Notifications")
@ApiExtraModels(
OrderNotificationPayloadDto,
InventoryNotificationPayloadDto,
SystemNotificationPayloadDto,
)
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("notifications")
export class NotificationsController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({
summary: "List polymorphic notifications using cursor pagination",
})
@ApiOkResponse({ type: NotificationsResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query() query: CursorQueryDto,
): NotificationsResponseDto {
return this.store.listNotifications(
request.organizationId!,
query.cursor,
query.limit,
);
}
@Post(":id/read")
@HttpCode(200)
@UseGuards(ComplexCsrfGuard)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Mark a notification as read" })
@ApiOkResponse({ type: NotificationResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
markRead(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): NotificationResponseDto {
return {
data: this.store.markNotificationRead(request.organizationId!, id),
};
}
}
@ApiTags("Files")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller("files")
export class FilesController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({ summary: "List uploaded file metadata" })
@ApiOkResponse({ type: FilesResponseDto })
@ApiStandardErrors({ auth: true })
list(@Req() request: ComplexDemoRequest): FilesResponseDto {
return { data: this.store.listFiles(request.organizationId!) };
}
@Post()
@UseInterceptors(
FileInterceptor("file", { limits: { fileSize: 5 * 1024 * 1024 } }),
)
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiSecurity("csrf")
@ApiConsumes("multipart/form-data")
@ApiBody({
schema: {
type: "object",
required: ["file"],
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiOperation({
summary: "Upload a file up to 5 MiB and retain it in memory",
})
@ApiCreatedResponse({ type: FileResponseDto })
@ApiStandardErrors({ auth: true })
upload(
@Req() request: ComplexDemoRequest,
@UploadedFile() file?: Express.Multer.File,
): FileResponseDto {
if (!file)
throw new BadRequestException({
code: "FILE_REQUIRED",
message: 'Multipart field "file" is required.',
});
return { data: this.store.addFile(request.organizationId!, file) };
}
@Get(":id/download")
@ApiProduces("application/octet-stream")
@ApiOperation({ summary: "Download an in-memory file" })
@ApiBinaryResponse("Binary file content.")
@ApiStandardErrors({ auth: true, notFound: true })
download(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Res({ passthrough: true }) response: Response,
): StreamableFile {
const file = this.store.getFile(request.organizationId!, id);
response.setHeader("Content-Type", file.metadata.mimeType);
response.setHeader(
"Content-Disposition",
`attachment; filename="${file.metadata.name.replace(/"/g, "")}"`,
);
return new StreamableFile(file.content);
}
}
@ApiTags("Audit")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard, ComplexRolesGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@Controller("audit-events")
export class AuditController {
constructor(private readonly store: ComplexStore) {}
@Get()
@ApiOperation({
summary: "List immutable audit events using offset pagination",
})
@ApiOkResponse({ type: AuditEventsResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: ComplexDemoRequest,
@Query() query: AuditQueryDto,
): AuditEventsResponseDto {
return this.store.listAudit(request.organizationId!, query);
}
}
@ApiTags("Background jobs")
@ApiCookieAuth("cookieSession")
@ApiOrganizationHeader()
@ApiDemoScenarioHeader()
@UseGuards(ComplexCookieGuard, ComplexOrganizationGuard)
@Controller()
export class JobsController {
constructor(private readonly store: ComplexStore) {}
@Post("exports/orders")
@HttpCode(202)
@UseGuards(ComplexRolesGuard, ComplexCsrfGuard)
@ComplexRoles(ComplexRole.Admin, ComplexRole.Manager)
@ApiSecurity("csrf")
@ApiOperation({ summary: "Start an asynchronous orders export" })
@ApiAcceptedResponse({ type: JobResponseDto })
@ApiStandardErrors({ auth: true })
startExport(@Req() request: ComplexDemoRequest): JobResponseDto {
return { data: this.store.startOrdersExport(request.organizationId!) };
}
@Get("jobs/:id")
@ApiOperation({ summary: "Poll background job progress" })
@ApiOkResponse({ type: JobResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
getJob(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
): JobResponseDto {
return { data: this.store.getJob(request.organizationId!, id) };
}
@Get("jobs/:id/result")
@ApiProduces("text/csv")
@ApiOperation({
summary: "Download a completed export; returns 409 while processing",
})
@ApiBinaryResponse("Generated orders CSV.", "text/csv")
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
result(
@Req() request: ComplexDemoRequest,
@Param("id") id: string,
@Res({ passthrough: true }) response: Response,
): StreamableFile {
const content = this.store.jobResult(request.organizationId!, id);
response.setHeader("Content-Type", "text/csv");
response.setHeader(
"Content-Disposition",
`attachment; filename="orders-${id}.csv"`,
);
return new StreamableFile(content);
}
}

View File

@@ -0,0 +1,177 @@
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import {
ApiCookieAuth,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiSecurity,
ApiTags,
} from "@nestjs/swagger";
import {
ApiDemoScenarioHeader,
ApiStandardErrors,
} from "../../../common/api.decorators";
import {
HealthResponseDto,
ScenariosResponseDto,
TestingActionResponseDto,
} from "../../../common/api.dto";
import {
ComplexCookieGuard,
ComplexCsrfGuard,
type ComplexDemoRequest,
ComplexSessionService,
} from "../complex.auth";
import { ComplexStore } from "../complex.store";
import {
ChangeComplexRoleDto,
ComplexUserResponseDto,
} from "../dto/identity.dto";
@ApiTags("Health")
@ApiDemoScenarioHeader()
@Controller("health")
export class ComplexHealthController {
@Get()
@ApiOperation({ summary: "Check Complex API availability" })
@ApiOkResponse({ type: HealthResponseDto })
health(): HealthResponseDto {
return {
data: {
application: "complex",
status: "ok",
timestamp: new Date().toISOString(),
version: "1.0.0",
},
};
}
}
@ApiTags("Testing")
@ApiDemoScenarioHeader()
@Controller("testing")
export class ComplexTestingController {
constructor(
private readonly store: ComplexStore,
private readonly sessions: ComplexSessionService,
) {}
@Get("scenarios")
@ApiOperation({ summary: "List deterministic X-Demo-Scenario values" })
@ApiOkResponse({ type: ScenariosResponseDto })
scenarios(): ScenariosResponseDto {
return {
data: [
{ name: "normal", description: "Default behavior." },
{
name: "slow",
description:
"Delays the response to exercise loading and cancellation states.",
},
{
name: "timeout",
description:
"Delays long enough for a frontend timeout or cancellation.",
},
{
name: "server-error",
description: "Returns a deterministic 500 error.",
},
{ name: "rate-limited", description: "Returns 429 with Retry-After." },
{
name: "empty",
description: "Turns list responses into a valid empty state.",
},
{
name: "expired-auth",
description: "Makes protected routes return 401.",
},
{
name: "forbidden",
description: "Makes protected routes return 403.",
},
{ name: "conflict", description: "Makes mutation routes return 409." },
{
name: "large-dataset",
description: "Expands list responses to 250 deterministic items.",
},
],
};
}
@Post("reset")
@HttpCode(200)
@ApiOperation({ summary: "Reset all Complex API data and active sessions" })
@ApiOkResponse({ type: TestingActionResponseDto })
reset(): TestingActionResponseDto {
this.store.reset("small");
this.sessions.reset();
return {
data: {
success: true,
message:
"Complex API state and sessions reset to the default deterministic seed.",
},
};
}
@Post("seed/:preset")
@HttpCode(200)
@ApiParam({ name: "preset", enum: ["small", "large"] })
@ApiOperation({ summary: "Select a small or large deterministic dataset" })
@ApiOkResponse({ type: TestingActionResponseDto })
seed(@Param("preset") preset: string): TestingActionResponseDto {
if (preset !== "small" && preset !== "large") {
throw new BadRequestException({
code: "UNKNOWN_SEED_PRESET",
message: "Preset must be small or large.",
});
}
this.store.reset(preset);
return {
data: {
success: true,
message: `Complex API loaded the ${preset} dataset.`,
},
};
}
@Post("session/expire")
@HttpCode(200)
@UseGuards(ComplexCookieGuard, ComplexCsrfGuard)
@ApiCookieAuth("cookieSession")
@ApiSecurity("csrf")
@ApiOperation({
summary: "Expire the current cookie session after this response",
})
@ApiOkResponse({ type: TestingActionResponseDto })
@ApiStandardErrors({ auth: true })
expireSession(@Req() request: ComplexDemoRequest): TestingActionResponseDto {
this.sessions.expire(request.sessionToken);
return {
data: { success: true, message: "Current cookie session expired." },
};
}
@Post("users/:userId/role")
@HttpCode(200)
@ApiOperation({ summary: "Change a role while sessions remain active" })
@ApiOkResponse({ type: ComplexUserResponseDto })
@ApiStandardErrors({ notFound: true })
changeRole(
@Param("userId") userId: string,
@Body() dto: ChangeComplexRoleDto,
): ComplexUserResponseDto {
return { data: this.store.changeUserRole(userId, dto.role) };
}
}

View File

@@ -0,0 +1,329 @@
import { Type } from "class-transformer";
import {
IsArray,
IsEnum,
IsInt,
IsObject,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateNested,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { CursorMetaDto } from "../../../common/api.dto";
export class MoneyDto {
@ApiProperty({
example: "129.90",
pattern: "^\\d+\\.\\d{2}$",
description:
"Decimal string; never parse money as a floating-point number.",
})
amount!: string;
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
currency!: "USD" | "EUR";
}
export enum ComplexProductStatus {
Draft = "draft",
Active = "active",
Archived = "archived",
}
export class ProductVariantDto {
@ApiProperty({ example: "variant-keyboard-black" })
id!: string;
@ApiProperty({ example: "KEYBOARD-BLACK-US" })
sku!: string;
@ApiProperty({
type: "object",
additionalProperties: { type: "string" },
example: { color: "black", layout: "US" },
})
attributes!: Record<string, string>;
@ApiProperty({ type: MoneyDto })
price!: MoneyDto;
}
export class ComplexProductDto {
@ApiProperty({ example: "complex-product-keyboard" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Pro Mechanical Keyboard" })
name!: string;
@ApiProperty({ example: "pro-mechanical-keyboard" })
slug!: string;
@ApiProperty({ example: "Configurable keyboard sold in multiple variants." })
description!: string;
@ApiProperty({ enum: ComplexProductStatus })
status!: ComplexProductStatus;
@ApiProperty({ example: "complex-category-electronics" })
categoryId!: string;
@ApiProperty({ example: "brand-northstar" })
brandId!: string;
@ApiProperty({ type: MoneyDto })
price!: MoneyDto;
@ApiProperty({ type: [ProductVariantDto] })
variants!: ProductVariantDto[];
@ApiProperty({ type: [String], example: ["featured", "office"] })
tags!: string[];
@ApiProperty({ format: "date-time", nullable: true })
publishedAt!: string | null;
@ApiProperty({ format: "date-time" })
createdAt!: string;
@ApiProperty({ example: 3, description: "Optimistic-lock version." })
version!: number;
}
export class CursorProductQueryDto {
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "complex-product-mouse" })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ example: "keyboard" })
@IsString()
@IsOptional()
search?: string;
@ApiPropertyOptional({ enum: ComplexProductStatus })
@IsEnum(ComplexProductStatus)
@IsOptional()
status?: ComplexProductStatus;
}
export class ComplexProductsResponseDto {
@ApiProperty({ type: [ComplexProductDto] })
data!: ComplexProductDto[];
@ApiProperty({ type: CursorMetaDto })
meta!: CursorMetaDto;
}
export class ComplexProductResponseDto {
@ApiProperty({ type: ComplexProductDto })
data!: ComplexProductDto;
}
export class CreateVariantDto {
@ApiProperty({ example: "KEYBOARD-WHITE-US" })
@IsString()
sku!: string;
@ApiProperty({
type: "object",
additionalProperties: { type: "string" },
example: { color: "white", layout: "US" },
})
@IsObject()
attributes!: Record<string, string>;
@ApiProperty({ example: "139.90" })
@IsString()
priceAmount!: string;
}
export class CreateComplexProductDto {
@ApiProperty({ example: "Pro Mechanical Keyboard" })
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@ApiProperty({ example: "Configurable keyboard sold in multiple variants." })
@IsString()
@MinLength(10)
@MaxLength(2000)
description!: string;
@ApiProperty({
enum: ComplexProductStatus,
default: ComplexProductStatus.Draft,
})
@IsEnum(ComplexProductStatus)
status!: ComplexProductStatus;
@ApiProperty({ example: "complex-category-electronics" })
@IsString()
categoryId!: string;
@ApiProperty({ example: "brand-northstar" })
@IsString()
brandId!: string;
@ApiProperty({ example: "129.90" })
@IsString()
priceAmount!: string;
@ApiProperty({ type: [CreateVariantDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateVariantDto)
variants!: CreateVariantDto[];
@ApiPropertyOptional({ type: [String], example: ["featured"] })
@IsArray()
@IsString({ each: true })
@IsOptional()
tags: string[] = [];
}
export class UpdateComplexProductDto extends PartialType(
CreateComplexProductDto,
) {
@ApiProperty({
minimum: 1,
example: 3,
description: "Version last read by the frontend.",
})
@IsInt()
@Min(1)
version!: number;
}
export class ComplexCategoryDto {
@ApiProperty({ example: "complex-category-electronics" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Electronics" })
name!: string;
@ApiProperty({ nullable: true, example: null })
parentId!: string | null;
@ApiProperty({ type: [String], example: ["complex-category-keyboards"] })
childIds!: string[];
}
export class ComplexCategoriesResponseDto {
@ApiProperty({ type: [ComplexCategoryDto] })
data!: ComplexCategoryDto[];
}
export class BrandDto {
@ApiProperty({ example: "brand-northstar" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Northstar" })
name!: string;
@ApiProperty({ format: "uri", nullable: true })
logoUrl!: string | null;
}
export class BrandsResponseDto {
@ApiProperty({ type: [BrandDto] })
data!: BrandDto[];
}
export class WarehouseDto {
@ApiProperty({ example: "warehouse-berlin" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Berlin Warehouse" })
name!: string;
@ApiProperty({ example: "DE" })
countryCode!: string;
@ApiProperty({ enum: ["active", "maintenance"], example: "active" })
status!: "active" | "maintenance";
}
export class WarehousesResponseDto {
@ApiProperty({ type: [WarehouseDto] })
data!: WarehouseDto[];
}
export class InventoryItemDto {
@ApiProperty({ example: "inventory-keyboard-berlin" })
id!: string;
@ApiProperty({ example: "complex-product-keyboard" })
productId!: string;
@ApiProperty({ example: "variant-keyboard-black" })
variantId!: string;
@ApiProperty({ example: "warehouse-berlin" })
warehouseId!: string;
@ApiProperty({ example: 42 })
available!: number;
@ApiProperty({ example: 5 })
reserved!: number;
@ApiProperty({ example: 10 })
reorderPoint!: number;
@ApiProperty({ example: 2 })
version!: number;
}
export class InventoryResponseDto {
@ApiProperty({ type: [InventoryItemDto] })
data!: InventoryItemDto[];
}
export class InventoryItemResponseDto {
@ApiProperty({ type: InventoryItemDto })
data!: InventoryItemDto;
}
export class AdjustInventoryDto {
@ApiProperty({ example: -2, description: "Signed stock adjustment." })
@IsInt()
delta!: number;
@ApiProperty({ example: "Damaged during delivery" })
@IsString()
@MinLength(3)
reason!: string;
@ApiProperty({
example: 2,
description: "Version last read by the frontend.",
})
@IsInt()
@Min(1)
version!: number;
}

View File

@@ -0,0 +1,87 @@
import { IsString, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { CursorMetaDto } from "../../../common/api.dto";
export class ConversationDto {
@ApiProperty({ example: "conversation-support" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Order support" })
title!: string;
@ApiProperty({
type: [String],
example: ["complex-user-admin", "complex-user-support"],
})
participantIds!: string[];
@ApiProperty({
nullable: true,
example: "Can you check order complex-order-001?",
})
lastMessagePreview!: string | null;
@ApiProperty({ format: "date-time" })
updatedAt!: string;
}
export class ConversationsResponseDto {
@ApiProperty({ type: [ConversationDto] })
data!: ConversationDto[];
}
export class ConversationResponseDto {
@ApiProperty({ type: ConversationDto })
data!: ConversationDto;
}
export class ChatMessageDto {
@ApiProperty({ example: "message-001" })
id!: string;
@ApiProperty({ example: "conversation-support" })
conversationId!: string;
@ApiProperty({ example: "complex-user-support" })
senderId!: string;
@ApiProperty({ example: "The order has already been packed." })
text!: string;
@ApiProperty({
example: "client-message-4c08",
description: "Frontend-generated key used to deduplicate retries.",
})
clientMessageId!: string;
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class MessagesResponseDto {
@ApiProperty({ type: [ChatMessageDto] })
data!: ChatMessageDto[];
@ApiProperty({ type: CursorMetaDto })
meta!: CursorMetaDto;
}
export class SendMessageDto {
@ApiProperty({ example: "The order has already been packed." })
@IsString()
@MinLength(1)
text!: string;
@ApiProperty({ example: "client-message-4c08" })
@IsString()
@MinLength(3)
clientMessageId!: string;
}
export class MessageResponseDto {
@ApiProperty({ type: ChatMessageDto })
data!: ChatMessageDto;
}

View File

@@ -0,0 +1,324 @@
import { Type } from "class-transformer";
import {
IsArray,
IsInt,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { CursorMetaDto, PageMetaDto } from "../../../common/api.dto";
import { MoneyDto } from "./catalog.dto";
export class AddressDto {
@ApiProperty({ example: "Friedrichstrasse 100" })
line1!: string;
@ApiProperty({ nullable: true, example: null })
line2!: string | null;
@ApiProperty({ example: "Berlin" })
city!: string;
@ApiProperty({ example: "10117" })
postalCode!: string;
@ApiProperty({ example: "DE" })
countryCode!: string;
}
export class CustomerDto {
@ApiProperty({ example: "customer-ada" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "Ada Lovelace" })
name!: string;
@ApiProperty({ format: "email", example: "ada@example.test" })
email!: string;
@ApiProperty({ type: AddressDto })
defaultAddress!: AddressDto;
@ApiProperty({ type: [String], example: ["vip", "newsletter"] })
tags!: string[];
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class CustomerQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "ada" })
@IsString()
@IsOptional()
search?: string;
}
export class CustomersResponseDto {
@ApiProperty({ type: [CustomerDto] })
data!: CustomerDto[];
@ApiProperty({ type: PageMetaDto })
meta!: PageMetaDto;
}
export class CustomerResponseDto {
@ApiProperty({ type: CustomerDto })
data!: CustomerDto;
}
export enum ComplexOrderStatus {
Draft = "draft",
AwaitingPayment = "awaiting-payment",
Paid = "paid",
Fulfillment = "fulfillment",
Shipped = "shipped",
Cancelled = "cancelled",
}
export class ComplexOrderItemDto {
@ApiProperty({ example: "complex-product-keyboard" })
productId!: string;
@ApiProperty({ example: "variant-keyboard-black" })
variantId!: string;
@ApiProperty({ example: "Pro Mechanical Keyboard" })
name!: string;
@ApiProperty({ example: 1 })
quantity!: number;
@ApiProperty({ type: MoneyDto })
unitPrice!: MoneyDto;
}
export class ComplexOrderDto {
@ApiProperty({ example: "complex-order-001" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "customer-ada" })
customerId!: string;
@ApiProperty({ enum: ComplexOrderStatus })
status!: ComplexOrderStatus;
@ApiProperty({ type: [ComplexOrderItemDto] })
items!: ComplexOrderItemDto[];
@ApiProperty({ type: MoneyDto })
subtotal!: MoneyDto;
@ApiProperty({ type: MoneyDto })
discount!: MoneyDto;
@ApiProperty({ type: MoneyDto })
total!: MoneyDto;
@ApiProperty({ type: AddressDto })
shippingAddress!: AddressDto;
@ApiProperty({ format: "date-time" })
createdAt!: string;
@ApiProperty({ example: 1 })
version!: number;
}
export class OrderCursorQueryDto {
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "complex-order-001" })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ enum: ComplexOrderStatus })
@IsString()
@IsOptional()
status?: ComplexOrderStatus;
}
export class ComplexOrdersResponseDto {
@ApiProperty({ type: [ComplexOrderDto] })
data!: ComplexOrderDto[];
@ApiProperty({ type: CursorMetaDto })
meta!: CursorMetaDto;
}
export class ComplexOrderResponseDto {
@ApiProperty({ type: ComplexOrderDto })
data!: ComplexOrderDto;
}
export class CreateComplexOrderItemDto {
@ApiProperty({ example: "complex-product-keyboard" })
@IsString()
productId!: string;
@ApiProperty({ example: "variant-keyboard-black" })
@IsString()
variantId!: string;
@ApiProperty({ example: 1, minimum: 1, maximum: 50 })
@IsInt()
@Min(1)
@Max(50)
quantity!: number;
}
export class CreateComplexOrderDto {
@ApiProperty({ example: "customer-ada" })
@IsString()
customerId!: string;
@ApiProperty({ type: [CreateComplexOrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateComplexOrderItemDto)
items!: CreateComplexOrderItemDto[];
@ApiPropertyOptional({ example: "WELCOME10" })
@IsString()
@IsOptional()
promotionCode?: string;
}
export class PaymentDto {
@ApiProperty({ example: "payment-001" })
id!: string;
@ApiProperty({ example: "complex-order-001" })
orderId!: string;
@ApiProperty({
enum: ["pending", "succeeded", "failed", "refunded"],
example: "succeeded",
})
status!: "pending" | "succeeded" | "failed" | "refunded";
@ApiProperty({ enum: ["card", "bank-transfer"], example: "card" })
method!: "card" | "bank-transfer";
@ApiProperty({ type: MoneyDto })
amount!: MoneyDto;
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class PaymentsResponseDto {
@ApiProperty({ type: [PaymentDto] })
data!: PaymentDto[];
}
export class PromotionDto {
@ApiProperty({ example: "promotion-welcome" })
id!: string;
@ApiProperty({ example: "WELCOME10" })
code!: string;
@ApiProperty({ enum: ["percentage", "fixed"], example: "percentage" })
type!: "percentage" | "fixed";
@ApiProperty({ example: "10.00" })
value!: string;
@ApiProperty({ format: "date-time" })
validUntil!: string;
@ApiProperty({ example: true })
active!: boolean;
}
export class PromotionsResponseDto {
@ApiProperty({ type: [PromotionDto] })
data!: PromotionDto[];
}
export class ReviewDto {
@ApiProperty({ example: "review-001" })
id!: string;
@ApiProperty({ example: "complex-product-keyboard" })
productId!: string;
@ApiProperty({ example: "customer-ada" })
customerId!: string;
@ApiProperty({ example: 5, minimum: 1, maximum: 5 })
rating!: number;
@ApiProperty({ example: "Excellent keyboard for daily development." })
comment!: string;
@ApiProperty({
enum: ["pending", "published", "rejected"],
example: "published",
})
status!: "pending" | "published" | "rejected";
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class ReviewsResponseDto {
@ApiProperty({ type: [ReviewDto] })
data!: ReviewDto[];
}
export class CreateReviewDto {
@ApiProperty({ example: "complex-product-keyboard" })
@IsString()
productId!: string;
@ApiProperty({ example: "customer-ada" })
@IsString()
customerId!: string;
@ApiProperty({ minimum: 1, maximum: 5, example: 5 })
@IsInt()
@Min(1)
@Max(5)
rating!: number;
@ApiProperty({ example: "Excellent keyboard for daily development." })
@IsString()
comment!: string;
}
export class ReviewResponseDto {
@ApiProperty({ type: ReviewDto })
data!: ReviewDto;
}

View File

@@ -0,0 +1,139 @@
import { IsEmail, IsEnum, IsString, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export enum ComplexRole {
Admin = "admin",
Manager = "manager",
Support = "support",
Viewer = "viewer",
}
export class ComplexLoginDto {
@ApiProperty({ format: "email", example: "admin@complex.demo" })
@IsEmail()
email!: string;
@ApiProperty({ format: "password", example: "demo1234", minLength: 8 })
@IsString()
@MinLength(8)
password!: string;
}
export class ComplexUserDto {
@ApiProperty({ example: "complex-user-admin" })
id!: string;
@ApiProperty({ format: "email", example: "admin@complex.demo" })
email!: string;
@ApiProperty({ example: "Complex Admin" })
name!: string;
@ApiProperty({ enum: ComplexRole })
role!: ComplexRole;
@ApiProperty({ nullable: true, example: "https://i.pravatar.cc/160?img=20" })
avatarUrl!: string | null;
@ApiProperty({ type: [String], example: ["org-acme", "org-globex"] })
organizationIds!: string[];
}
export class ComplexUserResponseDto {
@ApiProperty({ type: ComplexUserDto })
data!: ComplexUserDto;
}
export class ComplexUsersResponseDto {
@ApiProperty({ type: [ComplexUserDto] })
data!: ComplexUserDto[];
}
export class CookieSessionDataDto {
@ApiProperty({ type: ComplexUserDto })
user!: ComplexUserDto;
@ApiProperty({
example: "5f2642ca-2ba4-4ee3-bf2e-d5d37a97686c",
description: "Send this value in X-CSRF-Token for authenticated mutations.",
})
csrfToken!: string;
@ApiProperty({ format: "date-time" })
expiresAt!: string;
}
export class CookieSessionResponseDto {
@ApiProperty({ type: CookieSessionDataDto })
data!: CookieSessionDataDto;
}
export enum OrganizationPlan {
Starter = "starter",
Business = "business",
Enterprise = "enterprise",
}
export class OrganizationDto {
@ApiProperty({ example: "org-acme" })
id!: string;
@ApiProperty({ example: "Acme Commerce" })
name!: string;
@ApiProperty({ enum: OrganizationPlan })
plan!: OrganizationPlan;
@ApiProperty({ example: "Europe/Berlin" })
timezone!: string;
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
currency!: "USD" | "EUR";
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class OrganizationsResponseDto {
@ApiProperty({ type: [OrganizationDto] })
data!: OrganizationDto[];
}
export class OrganizationResponseDto {
@ApiProperty({ type: OrganizationDto })
data!: OrganizationDto;
}
export class MemberDto {
@ApiProperty({ example: "member-001" })
id!: string;
@ApiProperty({ example: "org-acme" })
organizationId!: string;
@ApiProperty({ example: "complex-user-manager" })
userId!: string;
@ApiProperty({ example: "Complex Manager" })
userName!: string;
@ApiProperty({ format: "email", example: "manager@complex.demo" })
email!: string;
@ApiProperty({ enum: ComplexRole })
role!: ComplexRole;
@ApiProperty({ enum: ["active", "invited", "suspended"], example: "active" })
status!: "active" | "invited" | "suspended";
}
export class MembersResponseDto {
@ApiProperty({ type: [MemberDto] })
data!: MemberDto[];
}
export class ChangeComplexRoleDto {
@ApiProperty({ enum: ComplexRole, example: ComplexRole.Viewer })
@IsEnum(ComplexRole)
role!: ComplexRole;
}

View File

@@ -0,0 +1,244 @@
import { Type } from "class-transformer";
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
import {
ApiExtraModels,
ApiProperty,
ApiPropertyOptional,
getSchemaPath,
} from "@nestjs/swagger";
import { CursorMetaDto, PageMetaDto } from "../../../common/api.dto";
export class OrderNotificationPayloadDto {
@ApiProperty({ enum: ["order"], example: "order" })
type!: "order";
@ApiProperty({ example: "complex-order-001" })
orderId!: string;
@ApiProperty({ enum: ["paid", "shipped", "cancelled"], example: "shipped" })
status!: string;
}
export class InventoryNotificationPayloadDto {
@ApiProperty({ enum: ["inventory"], example: "inventory" })
type!: "inventory";
@ApiProperty({ example: "complex-product-keyboard" })
productId!: string;
@ApiProperty({ example: 4 })
remaining!: number;
}
export class SystemNotificationPayloadDto {
@ApiProperty({ enum: ["system"], example: "system" })
type!: "system";
@ApiProperty({ example: "Scheduled maintenance begins at 02:00 UTC." })
text!: string;
}
export enum NotificationKind {
Order = "order",
Inventory = "inventory",
System = "system",
}
@ApiExtraModels(
OrderNotificationPayloadDto,
InventoryNotificationPayloadDto,
SystemNotificationPayloadDto,
)
export class NotificationDto {
@ApiProperty({ example: "notification-001" })
id!: string;
@ApiProperty({ enum: NotificationKind })
kind!: NotificationKind;
@ApiProperty({ example: "Order shipped" })
title!: string;
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(OrderNotificationPayloadDto) },
{ $ref: getSchemaPath(InventoryNotificationPayloadDto) },
{ $ref: getSchemaPath(SystemNotificationPayloadDto) },
],
discriminator: {
propertyName: "type",
mapping: {
order: getSchemaPath(OrderNotificationPayloadDto),
inventory: getSchemaPath(InventoryNotificationPayloadDto),
system: getSchemaPath(SystemNotificationPayloadDto),
},
},
})
payload!:
| OrderNotificationPayloadDto
| InventoryNotificationPayloadDto
| SystemNotificationPayloadDto;
@ApiProperty({ example: false })
read!: boolean;
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class NotificationsResponseDto {
@ApiProperty({ type: [NotificationDto] })
data!: NotificationDto[];
@ApiProperty({ type: CursorMetaDto })
meta!: CursorMetaDto;
}
export class NotificationResponseDto {
@ApiProperty({ type: NotificationDto })
data!: NotificationDto;
}
export class CursorQueryDto {
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "notification-020" })
@IsString()
@IsOptional()
cursor?: string;
}
export class FileMetadataDto {
@ApiProperty({ example: "file-001" })
id!: string;
@ApiProperty({ example: "products.csv" })
name!: string;
@ApiProperty({ example: "text/csv" })
mimeType!: string;
@ApiProperty({ example: 18432 })
size!: number;
@ApiProperty({ format: "uri", example: "/api/v1/files/file-001/download" })
downloadUrl!: string;
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class FileResponseDto {
@ApiProperty({ type: FileMetadataDto })
data!: FileMetadataDto;
}
export class FilesResponseDto {
@ApiProperty({ type: [FileMetadataDto] })
data!: FileMetadataDto[];
}
export class AuditEventDto {
@ApiProperty({ example: "audit-001" })
id!: string;
@ApiProperty({ example: "product.updated" })
action!: string;
@ApiProperty({ example: "complex-user-admin" })
actorId!: string;
@ApiProperty({ example: "product" })
resourceType!: string;
@ApiProperty({ example: "complex-product-keyboard" })
resourceId!: string;
@ApiProperty({
type: "object",
additionalProperties: true,
example: { version: 4 },
})
metadata!: Record<string, unknown>;
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class AuditQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "product.updated" })
@IsString()
@IsOptional()
action?: string;
}
export class AuditEventsResponseDto {
@ApiProperty({ type: [AuditEventDto] })
data!: AuditEventDto[];
@ApiProperty({ type: PageMetaDto })
meta!: PageMetaDto;
}
export enum JobStatus {
Pending = "pending",
Processing = "processing",
Completed = "completed",
Failed = "failed",
}
export class JobDto {
@ApiProperty({ example: "job-001" })
id!: string;
@ApiProperty({ enum: ["orders-export"], example: "orders-export" })
type!: "orders-export";
@ApiProperty({ enum: JobStatus })
status!: JobStatus;
@ApiProperty({ example: 75, minimum: 0, maximum: 100 })
progress!: number;
@ApiProperty({
format: "uri",
nullable: true,
example: "/api/v1/jobs/job-001/result",
})
resultUrl!: string | null;
@ApiProperty({ nullable: true, example: null })
error!: string | null;
@ApiProperty({ format: "date-time" })
createdAt!: string;
@ApiProperty({ format: "date-time" })
updatedAt!: string;
}
export class JobResponseDto {
@ApiProperty({ type: JobDto })
data!: JobDto;
}

View File

@@ -0,0 +1,12 @@
import { createComplexApplication } from "./bootstrap";
async function bootstrap(): Promise<void> {
const { app } = await createComplexApplication();
const port = Number(process.env.COMPLEX_PORT ?? 3002);
await app.listen(port);
console.log(`Complex API: http://localhost:${port}/api/v1`);
console.log(`Complex Swagger: http://localhost:${port}/docs`);
console.log(`Complex Socket.IO namespace: ws://localhost:${port}/chat`);
}
void bootstrap();

View File

@@ -0,0 +1,20 @@
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import type { OpenAPIObject } from "@nestjs/swagger";
import { configureApplication } from "../../common/configure-application";
import { createOpenApiDocument, mountOpenApi } from "../../common/openapi";
import { SimpleAppModule } from "./simple.module";
export async function createSimpleApplication(
logger: false | undefined = undefined,
): Promise<{ app: NestExpressApplication; document: OpenAPIObject }> {
const app = await NestFactory.create<NestExpressApplication>(
SimpleAppModule,
{ logger },
);
configureApplication(app);
const port = Number(process.env.SIMPLE_PORT ?? 3001);
const document = createOpenApiDocument(app, { kind: "simple", port });
mountOpenApi(app, document, "Demo Simple API");
return { app, document };
}

View File

@@ -0,0 +1,11 @@
import { createSimpleApplication } from "./bootstrap";
async function bootstrap(): Promise<void> {
const { app } = await createSimpleApplication();
const port = Number(process.env.SIMPLE_PORT ?? 3001);
await app.listen(port);
console.log(`Simple API: http://localhost:${port}/api/v1`);
console.log(`Simple Swagger: http://localhost:${port}/docs`);
}
void bootstrap();

View File

@@ -0,0 +1,197 @@
import { randomUUID } from "node:crypto";
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import type { Request } from "express";
import type { DemoRequest } from "../../common/request.types";
import { assertNoForcedAuthFailure } from "../../common/scenario.interceptor";
import {
type JwtAuthResponseDto,
type JwtTokensDto,
type LoginDto,
type RefreshTokenDto,
SimpleRole,
} from "./simple.dto";
import { SimpleStore } from "./simple.store";
interface DemoJwtPayload {
sub: string;
type: "access" | "refresh";
jti: string;
}
@Injectable()
export class SimpleAuthService {
private readonly revokedRefreshTokens = new Set<string>();
constructor(
private readonly jwtService: JwtService,
private readonly store: SimpleStore,
) {}
async login(dto: LoginDto): Promise<JwtAuthResponseDto> {
const user = this.store.findUserByEmail(dto.email);
if (!user || user.password !== dto.password) {
throw new UnauthorizedException({
code: "INVALID_CREDENTIALS",
message: "Email or password is incorrect.",
});
}
return {
data: {
tokens: await this.issueTokens(user.id),
user: this.store.publicUser(user),
},
};
}
async refresh(dto: RefreshTokenDto): Promise<JwtAuthResponseDto> {
let payload: DemoJwtPayload;
try {
payload = await this.jwtService.verifyAsync<DemoJwtPayload>(
dto.refreshToken,
{ secret: this.refreshSecret },
);
} catch {
throw new UnauthorizedException({
code: "INVALID_REFRESH_TOKEN",
message: "Refresh token is invalid or expired.",
});
}
if (
payload.type !== "refresh" ||
this.revokedRefreshTokens.has(payload.jti)
) {
throw new UnauthorizedException({
code: "REFRESH_TOKEN_REUSED",
message: "Refresh token was revoked or already used.",
});
}
const user = this.store.findUserById(payload.sub);
if (!user) {
throw new UnauthorizedException({
code: "USER_NOT_FOUND",
message: "Token user no longer exists.",
});
}
this.revokedRefreshTokens.add(payload.jti);
return {
data: {
tokens: await this.issueTokens(user.id),
user: this.store.publicUser(user),
},
};
}
async logout(dto: RefreshTokenDto): Promise<void> {
try {
const payload = await this.jwtService.verifyAsync<DemoJwtPayload>(
dto.refreshToken,
{ secret: this.refreshSecret },
);
this.revokedRefreshTokens.add(payload.jti);
} catch {
// Logout remains idempotent even when the token has already expired.
}
}
reset(): void {
this.revokedRefreshTokens.clear();
}
private async issueTokens(userId: string): Promise<JwtTokensDto> {
const accessJti = randomUUID();
const refreshJti = randomUUID();
const accessTtl = process.env.JWT_ACCESS_TTL ?? "60s";
const refreshTtl = process.env.JWT_REFRESH_TTL ?? "7d";
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{ sub: userId, type: "access", jti: accessJti },
{ secret: this.accessSecret, expiresIn: accessTtl as never },
),
this.jwtService.signAsync(
{ sub: userId, type: "refresh", jti: refreshJti },
{ secret: this.refreshSecret, expiresIn: refreshTtl as never },
),
]);
return {
accessToken,
refreshToken,
expiresIn: this.accessTtlSeconds(accessTtl),
tokenType: "Bearer",
};
}
private accessTtlSeconds(value: string): number {
const match = /^(\d+)([smhd])$/.exec(value);
if (!match) return 60;
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
return Number(match[1]) * multipliers[match[2] as keyof typeof multipliers];
}
private get accessSecret(): string {
return process.env.JWT_ACCESS_SECRET ?? "demo-access-secret-change-me";
}
private get refreshSecret(): string {
return process.env.JWT_REFRESH_SECRET ?? "demo-refresh-secret-change-me";
}
}
@Injectable()
export class SimpleJwtGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly store: SimpleStore,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>() as DemoRequest;
assertNoForcedAuthFailure(request);
const authorization = request.headers.authorization;
if (!authorization?.startsWith("Bearer ")) {
throw new UnauthorizedException({
code: "JWT_MISSING",
message: "Bearer access token is required.",
});
}
try {
const payload = await this.jwtService.verifyAsync<DemoJwtPayload>(
authorization.slice(7),
{
secret:
process.env.JWT_ACCESS_SECRET ?? "demo-access-secret-change-me",
},
);
if (payload.type !== "access") throw new Error("Wrong token type");
const user = this.store.findUserById(payload.sub);
if (!user) throw new Error("Unknown user");
request.user = this.store.publicUser(user);
return true;
} catch {
throw new UnauthorizedException({
code: "JWT_INVALID",
message: "Access token is invalid or expired.",
});
}
}
}
@Injectable()
export class SimpleAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<DemoRequest>();
if (request.user?.role !== SimpleRole.Admin) {
throw new ForbiddenException({
code: "ADMIN_REQUIRED",
message: "Administrator role is required.",
});
}
return true;
}
}

View File

@@ -0,0 +1,399 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiNoContentResponse,
ApiNotModifiedResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import {
ApiDemoScenarioHeader,
ApiStandardErrors,
} from "../../common/api.decorators";
import {
HealthResponseDto,
MutationResponseDto,
ScenariosResponseDto,
TestingActionResponseDto,
} from "../../common/api.dto";
import type { DemoRequest } from "../../common/request.types";
import {
SimpleAdminGuard,
SimpleAuthService,
SimpleJwtGuard,
} from "./simple.auth";
import {
CategoriesResponseDto,
CategoryResponseDto,
ChangeSimpleRoleDto,
CreateOrderDto,
CreateProductDto,
JwtAuthResponseDto,
LoginDto,
OrderResponseDto,
OrdersResponseDto,
PageQueryDto,
ProductQueryDto,
ProductResponseDto,
ProductsResponseDto,
RefreshTokenDto,
SimpleRole,
SimpleUserResponseDto,
UpdateProductDto,
} from "./simple.dto";
import { SimpleStore } from "./simple.store";
@ApiTags("Health")
@ApiDemoScenarioHeader()
@Controller("health")
export class SimpleHealthController {
@Get()
@ApiOperation({ summary: "Check Simple API availability" })
@ApiOkResponse({ type: HealthResponseDto })
health(): HealthResponseDto {
return {
data: {
application: "simple",
status: "ok",
timestamp: new Date().toISOString(),
version: "1.0.0",
},
};
}
}
@ApiTags("Auth")
@ApiDemoScenarioHeader()
@Controller("auth")
export class SimpleAuthController {
constructor(private readonly auth: SimpleAuthService) {}
@Post("login")
@HttpCode(200)
@ApiOperation({ summary: "Login and receive JWT access/refresh tokens" })
@ApiOkResponse({ type: JwtAuthResponseDto })
@ApiStandardErrors()
login(@Body() dto: LoginDto): Promise<JwtAuthResponseDto> {
return this.auth.login(dto);
}
@Post("refresh")
@HttpCode(200)
@ApiOperation({
summary: "Rotate a refresh token and issue a new token pair",
})
@ApiOkResponse({ type: JwtAuthResponseDto })
@ApiStandardErrors({ auth: true })
refresh(@Body() dto: RefreshTokenDto): Promise<JwtAuthResponseDto> {
return this.auth.refresh(dto);
}
@Post("logout")
@HttpCode(204)
@ApiOperation({
summary: "Revoke a refresh token; the operation is idempotent",
})
@ApiNoContentResponse()
@ApiStandardErrors()
async logout(@Body() dto: RefreshTokenDto): Promise<void> {
await this.auth.logout(dto);
}
}
@ApiTags("Users")
@ApiBearerAuth("jwt")
@ApiDemoScenarioHeader()
@UseGuards(SimpleJwtGuard)
@Controller("users")
export class SimpleUsersController {
constructor(private readonly store: SimpleStore) {}
@Get("me")
@ApiOperation({ summary: "Get the authenticated user" })
@ApiOkResponse({ type: SimpleUserResponseDto })
@ApiStandardErrors({ auth: true })
me(@Req() request: DemoRequest): SimpleUserResponseDto {
const user = this.store.findUserById(request.user!.id)!;
return { data: this.store.publicUser(user) };
}
}
@ApiTags("Products")
@ApiDemoScenarioHeader()
@Controller("products")
export class SimpleProductsController {
constructor(private readonly store: SimpleStore) {}
@Get()
@ApiOperation({
summary: "List products using offset pagination, filters and sorting",
})
@ApiOkResponse({ type: ProductsResponseDto })
@ApiStandardErrors()
list(@Query() query: ProductQueryDto): ProductsResponseDto {
return this.store.listProducts(query);
}
@Get(":id")
@ApiOperation({ summary: "Get one product with ETag support" })
@ApiOkResponse({ type: ProductResponseDto })
@ApiNotModifiedResponse({
description: "The supplied If-None-Match value is current.",
})
@ApiStandardErrors({ notFound: true })
get(
@Param("id") id: string,
@Headers("if-none-match") ifNoneMatch: string | undefined,
@Res({ passthrough: true }) response: Response,
): ProductResponseDto | undefined {
const product = this.store.getProduct(id);
const etag = `W/\"${product.id}-v${product.version}\"`;
response.setHeader("ETag", etag);
response.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
if (ifNoneMatch === etag) {
response.status(304);
return undefined;
}
return { data: product };
}
@Post()
@UseGuards(SimpleJwtGuard, SimpleAdminGuard)
@ApiBearerAuth("jwt")
@ApiOperation({ summary: "Create a product as an administrator" })
@ApiCreatedResponse({ type: ProductResponseDto })
@ApiStandardErrors({ auth: true })
create(@Body() dto: CreateProductDto): ProductResponseDto {
return { data: this.store.createProduct(dto) };
}
@Patch(":id")
@UseGuards(SimpleJwtGuard, SimpleAdminGuard)
@ApiBearerAuth("jwt")
@ApiOperation({ summary: "Update a product using optimistic locking" })
@ApiOkResponse({ type: ProductResponseDto })
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
update(
@Param("id") id: string,
@Body() dto: UpdateProductDto,
): ProductResponseDto {
return { data: this.store.updateProduct(id, dto) };
}
@Delete(":id")
@UseGuards(SimpleJwtGuard, SimpleAdminGuard)
@ApiBearerAuth("jwt")
@ApiOperation({ summary: "Delete a product as an administrator" })
@ApiOkResponse({ type: MutationResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
remove(@Param("id") id: string): MutationResponseDto {
this.store.deleteProduct(id);
return { data: { id, success: true } };
}
}
@ApiTags("Categories")
@ApiDemoScenarioHeader()
@Controller("categories")
export class SimpleCategoriesController {
constructor(private readonly store: SimpleStore) {}
@Get()
@ApiOperation({ summary: "List product categories" })
@ApiOkResponse({ type: CategoriesResponseDto })
list(): CategoriesResponseDto {
return { data: this.store.listCategories() };
}
@Get(":id")
@ApiOperation({ summary: "Get one category" })
@ApiOkResponse({ type: CategoryResponseDto })
@ApiStandardErrors({ notFound: true })
get(@Param("id") id: string): CategoryResponseDto {
return { data: this.store.getCategory(id) };
}
}
@ApiTags("Orders")
@ApiBearerAuth("jwt")
@ApiDemoScenarioHeader()
@UseGuards(SimpleJwtGuard)
@Controller("orders")
export class SimpleOrdersController {
constructor(private readonly store: SimpleStore) {}
@Get()
@ApiOperation({ summary: "List orders visible to the current user" })
@ApiOkResponse({ type: OrdersResponseDto })
@ApiStandardErrors({ auth: true })
list(
@Req() request: DemoRequest,
@Query() query: PageQueryDto,
): OrdersResponseDto {
return this.store.listOrders(
request.user!.id,
request.user!.role,
query.page,
query.limit,
);
}
@Get(":id")
@ApiOperation({ summary: "Get one visible order" })
@ApiOkResponse({ type: OrderResponseDto })
@ApiStandardErrors({ auth: true, notFound: true })
get(@Param("id") id: string, @Req() request: DemoRequest): OrderResponseDto {
return {
data: this.store.getOrder(id, request.user!.id, request.user!.role),
};
}
@Post()
@ApiOperation({ summary: "Create an order and validate product stock" })
@ApiCreatedResponse({ type: OrderResponseDto })
@ApiStandardErrors({ auth: true, conflict: true })
create(
@Req() request: DemoRequest,
@Body() dto: CreateOrderDto,
): OrderResponseDto {
return { data: this.store.createOrder(request.user!.id, dto) };
}
@Post(":id/cancel")
@HttpCode(200)
@ApiOperation({
summary: "Cancel an order if its state permits the transition",
})
@ApiOkResponse({ type: OrderResponseDto })
@ApiStandardErrors({ auth: true, notFound: true, conflict: true })
cancel(
@Param("id") id: string,
@Req() request: DemoRequest,
): OrderResponseDto {
return {
data: this.store.cancelOrder(id, request.user!.id, request.user!.role),
};
}
}
@ApiTags("Testing")
@ApiDemoScenarioHeader()
@Controller("testing")
export class SimpleTestingController {
constructor(
private readonly store: SimpleStore,
private readonly auth: SimpleAuthService,
) {}
@Get("scenarios")
@ApiOperation({ summary: "List deterministic X-Demo-Scenario values" })
@ApiOkResponse({ type: ScenariosResponseDto })
scenarios(): ScenariosResponseDto {
return {
data: [
{ name: "normal", description: "Default behavior." },
{
name: "slow",
description:
"Delays the response to exercise loading and cancellation states.",
},
{
name: "timeout",
description:
"Delays long enough for a frontend timeout or cancellation.",
},
{
name: "server-error",
description: "Returns a deterministic 500 error.",
},
{ name: "rate-limited", description: "Returns 429 with Retry-After." },
{
name: "empty",
description: "Turns list responses into a valid empty state.",
},
{
name: "expired-auth",
description: "Makes protected routes return 401.",
},
{
name: "forbidden",
description: "Makes protected routes return 403.",
},
{ name: "conflict", description: "Makes mutation routes return 409." },
{
name: "large-dataset",
description: "Expands list responses to 250 deterministic items.",
},
],
};
}
@Post("reset")
@HttpCode(200)
@ApiOperation({ summary: "Reset all Simple API state and token revocations" })
@ApiOkResponse({ type: TestingActionResponseDto })
reset(): TestingActionResponseDto {
this.store.reset("small");
this.auth.reset();
return {
data: {
success: true,
message: "Simple API state reset to the default deterministic seed.",
},
};
}
@Post("seed/:preset")
@HttpCode(200)
@ApiParam({ name: "preset", enum: ["small", "large"] })
@ApiOperation({ summary: "Select a small or large deterministic dataset" })
@ApiOkResponse({ type: TestingActionResponseDto })
seed(@Param("preset") preset: string): TestingActionResponseDto {
if (preset !== "small" && preset !== "large") {
throw new BadRequestException({
code: "UNKNOWN_SEED_PRESET",
message: "Preset must be small or large.",
});
}
this.store.reset(preset);
return {
data: {
success: true,
message: `Simple API loaded the ${preset} dataset.`,
},
};
}
@Post("users/:userId/role")
@HttpCode(200)
@ApiOperation({
summary: "Change a user role to exercise dynamic access control",
})
@ApiOkResponse({ type: SimpleUserResponseDto })
@ApiStandardErrors({ notFound: true })
changeRole(
@Param("userId") userId: string,
@Body() dto: ChangeSimpleRoleDto,
): SimpleUserResponseDto {
return { data: this.store.changeRole(userId, dto.role) };
}
}

View File

@@ -0,0 +1,365 @@
import { Type } from "class-transformer";
import {
IsArray,
IsEmail,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateNested,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { PageMetaDto } from "../../common/api.dto";
export enum SimpleRole {
Admin = "admin",
Customer = "customer",
}
export class SimpleUserDto {
@ApiProperty({ example: "user-admin" })
id!: string;
@ApiProperty({ format: "email", example: "admin@demo.local" })
email!: string;
@ApiProperty({ example: "Demo Admin" })
name!: string;
@ApiProperty({ enum: SimpleRole })
role!: SimpleRole;
@ApiProperty({ nullable: true, example: "https://i.pravatar.cc/160?img=12" })
avatarUrl!: string | null;
}
export class SimpleUserResponseDto {
@ApiProperty({ type: SimpleUserDto })
data!: SimpleUserDto;
}
export class LoginDto {
@ApiProperty({ format: "email", example: "admin@demo.local" })
@IsEmail()
email!: string;
@ApiProperty({ format: "password", example: "demo1234", minLength: 8 })
@IsString()
@MinLength(8)
password!: string;
}
export class RefreshTokenDto {
@ApiProperty({
description:
"Refresh token returned by login or the previous refresh call.",
})
@IsString()
@IsNotEmpty()
refreshToken!: string;
}
export class JwtTokensDto {
@ApiProperty()
accessToken!: string;
@ApiProperty()
refreshToken!: string;
@ApiProperty({
example: 60,
description: "Access-token lifetime in seconds.",
})
expiresIn!: number;
@ApiProperty({ enum: ["Bearer"], example: "Bearer" })
tokenType!: "Bearer";
}
export class JwtAuthDataDto {
@ApiProperty({ type: JwtTokensDto })
tokens!: JwtTokensDto;
@ApiProperty({ type: SimpleUserDto })
user!: SimpleUserDto;
}
export class JwtAuthResponseDto {
@ApiProperty({ type: JwtAuthDataDto })
data!: JwtAuthDataDto;
}
export enum ProductSort {
Newest = "newest",
PriceAsc = "price-asc",
PriceDesc = "price-desc",
Name = "name",
}
export class ProductQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
@ApiPropertyOptional({ example: "keyboard" })
@IsString()
@IsOptional()
search?: string;
@ApiPropertyOptional({ example: "category-electronics" })
@IsString()
@IsOptional()
categoryId?: string;
@ApiPropertyOptional({ enum: ProductSort, default: ProductSort.Newest })
@IsEnum(ProductSort)
@IsOptional()
sort = ProductSort.Newest;
}
export class PageQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit = 20;
}
export class SimpleProductDto {
@ApiProperty({ example: "product-keyboard" })
id!: string;
@ApiProperty({ example: "Mechanical Keyboard" })
name!: string;
@ApiProperty({ example: "mechanical-keyboard" })
slug!: string;
@ApiProperty({ example: "Hot-swappable compact keyboard." })
description!: string;
@ApiProperty({
example: 12990,
description: "Price in the smallest currency unit.",
})
priceCents!: number;
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
currency!: "USD" | "EUR";
@ApiProperty({ example: "category-electronics" })
categoryId!: string;
@ApiProperty({ example: 24, minimum: 0 })
stock!: number;
@ApiProperty({ example: 4.8, minimum: 0, maximum: 5 })
rating!: number;
@ApiProperty({
format: "uri",
example: "https://picsum.photos/seed/keyboard/640/480",
})
imageUrl!: string;
@ApiProperty({ format: "date-time" })
createdAt!: string;
@ApiProperty({ example: 1, description: "Optimistic-lock version." })
version!: number;
}
export class ProductsResponseDto {
@ApiProperty({ type: [SimpleProductDto] })
data!: SimpleProductDto[];
@ApiProperty({ type: PageMetaDto })
meta!: PageMetaDto;
}
export class ProductResponseDto {
@ApiProperty({ type: SimpleProductDto })
data!: SimpleProductDto;
}
export class CreateProductDto {
@ApiProperty({ example: "USB-C Dock" })
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@ApiProperty({ example: "Dock with HDMI, Ethernet and power delivery." })
@IsString()
@MinLength(10)
@MaxLength(1000)
description!: string;
@ApiProperty({ example: 8990, minimum: 0 })
@IsInt()
@Min(0)
priceCents!: number;
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
@IsEnum(["USD", "EUR"])
currency!: "USD" | "EUR";
@ApiProperty({ example: "category-electronics" })
@IsString()
categoryId!: string;
@ApiProperty({ example: 15, minimum: 0 })
@IsInt()
@Min(0)
stock!: number;
@ApiProperty({
format: "uri",
example: "https://picsum.photos/seed/dock/640/480",
})
@IsString()
imageUrl!: string;
}
export class UpdateProductDto extends PartialType(CreateProductDto) {
@ApiProperty({
example: 1,
minimum: 1,
description: "Version last read by the frontend.",
})
@IsInt()
@Min(1)
version!: number;
}
export class SimpleCategoryDto {
@ApiProperty({ example: "category-electronics" })
id!: string;
@ApiProperty({ example: "Electronics" })
name!: string;
@ApiProperty({ example: "electronics" })
slug!: string;
@ApiProperty({ example: 4 })
productCount!: number;
}
export class CategoriesResponseDto {
@ApiProperty({ type: [SimpleCategoryDto] })
data!: SimpleCategoryDto[];
}
export class CategoryResponseDto {
@ApiProperty({ type: SimpleCategoryDto })
data!: SimpleCategoryDto;
}
export enum OrderStatus {
Pending = "pending",
Paid = "paid",
Shipped = "shipped",
Cancelled = "cancelled",
}
export class CreateOrderItemDto {
@ApiProperty({ example: "product-keyboard" })
@IsString()
productId!: string;
@ApiProperty({ example: 1, minimum: 1, maximum: 20 })
@IsInt()
@Min(1)
@Max(20)
quantity!: number;
}
export class CreateOrderDto {
@ApiProperty({ type: [CreateOrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateOrderItemDto)
items!: CreateOrderItemDto[];
}
export class SimpleOrderItemDto {
@ApiProperty({ example: "product-keyboard" })
productId!: string;
@ApiProperty({ example: "Mechanical Keyboard" })
productName!: string;
@ApiProperty({ example: 1 })
quantity!: number;
@ApiProperty({ example: 12990 })
unitPriceCents!: number;
}
export class SimpleOrderDto {
@ApiProperty({ example: "order-001" })
id!: string;
@ApiProperty({ example: "user-customer" })
userId!: string;
@ApiProperty({ enum: OrderStatus })
status!: OrderStatus;
@ApiProperty({ type: [SimpleOrderItemDto] })
items!: SimpleOrderItemDto[];
@ApiProperty({ example: 17980 })
totalCents!: number;
@ApiProperty({ enum: ["USD", "EUR"], example: "USD" })
currency!: "USD" | "EUR";
@ApiProperty({ format: "date-time" })
createdAt!: string;
}
export class OrdersResponseDto {
@ApiProperty({ type: [SimpleOrderDto] })
data!: SimpleOrderDto[];
@ApiProperty({ type: PageMetaDto })
meta!: PageMetaDto;
}
export class OrderResponseDto {
@ApiProperty({ type: SimpleOrderDto })
data!: SimpleOrderDto;
}
export class ChangeSimpleRoleDto {
@ApiProperty({ enum: SimpleRole, example: SimpleRole.Customer })
@IsEnum(SimpleRole)
role!: SimpleRole;
}

View File

@@ -0,0 +1,33 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { InfrastructureModule } from "../../common/infrastructure.module";
import {
SimpleAdminGuard,
SimpleAuthService,
SimpleJwtGuard,
} from "./simple.auth";
import {
SimpleAuthController,
SimpleCategoriesController,
SimpleHealthController,
SimpleOrdersController,
SimpleProductsController,
SimpleTestingController,
SimpleUsersController,
} from "./simple.controllers";
import { SimpleStore } from "./simple.store";
@Module({
imports: [InfrastructureModule, JwtModule.register({})],
controllers: [
SimpleHealthController,
SimpleAuthController,
SimpleUsersController,
SimpleProductsController,
SimpleCategoriesController,
SimpleOrdersController,
SimpleTestingController,
],
providers: [SimpleStore, SimpleAuthService, SimpleJwtGuard, SimpleAdminGuard],
})
export class SimpleAppModule {}

View File

@@ -0,0 +1,432 @@
import {
ConflictException,
Injectable,
NotFoundException,
UnprocessableEntityException,
} from "@nestjs/common";
import {
type CreateOrderDto,
type CreateProductDto,
OrderStatus,
ProductSort,
type ProductQueryDto,
type SimpleCategoryDto,
type SimpleOrderDto,
type SimpleProductDto,
SimpleRole,
type SimpleUserDto,
type UpdateProductDto,
} from "./simple.dto";
interface SimpleUserRecord extends SimpleUserDto {
password: string;
}
@Injectable()
export class SimpleStore {
private users: SimpleUserRecord[] = [];
private categories: SimpleCategoryDto[] = [];
private products: SimpleProductDto[] = [];
private orders: SimpleOrderDto[] = [];
private productSequence = 10;
private orderSequence = 10;
constructor() {
this.reset("small");
}
reset(preset: "small" | "large" = "small"): void {
this.users = [
{
id: "user-admin",
email: "admin@demo.local",
password: "demo1234",
name: "Demo Admin",
role: SimpleRole.Admin,
avatarUrl: "https://i.pravatar.cc/160?img=12",
},
{
id: "user-customer",
email: "customer@demo.local",
password: "demo1234",
name: "Demo Customer",
role: SimpleRole.Customer,
avatarUrl: null,
},
];
this.categories = [
{
id: "category-electronics",
name: "Electronics",
slug: "electronics",
productCount: 0,
},
{
id: "category-home",
name: "Home office",
slug: "home-office",
productCount: 0,
},
{ id: "category-books", name: "Books", slug: "books", productCount: 0 },
];
const baseProducts: SimpleProductDto[] = [
this.product(
"product-keyboard",
"Mechanical Keyboard",
12990,
"category-electronics",
24,
4.8,
),
this.product(
"product-mouse",
"Ergonomic Mouse",
6990,
"category-electronics",
42,
4.6,
),
this.product(
"product-desk",
"Standing Desk",
45900,
"category-home",
8,
4.9,
),
this.product(
"product-lamp",
"Focus Desk Lamp",
5490,
"category-home",
31,
4.5,
),
this.product(
"product-book",
"Frontend Systems Handbook",
3990,
"category-books",
100,
4.7,
),
this.product(
"product-headphones",
"Studio Headphones",
18990,
"category-electronics",
12,
4.4,
),
];
this.products =
preset === "large"
? Array.from({ length: 250 }, (_, index) => {
const source = baseProducts[index % baseProducts.length];
const number = index + 1;
return {
...source,
id: `product-${String(number).padStart(3, "0")}`,
name: `${source.name} ${number}`,
slug: `${source.slug}-${number}`,
};
})
: baseProducts;
this.orders = [
{
id: "order-001",
userId: "user-customer",
status: OrderStatus.Paid,
items: [
{
productId: "product-keyboard",
productName: "Mechanical Keyboard",
quantity: 1,
unitPriceCents: 12990,
},
],
totalCents: 12990,
currency: "USD",
createdAt: "2026-07-20T10:30:00.000Z",
},
{
id: "order-002",
userId: "user-customer",
status: OrderStatus.Shipped,
items: [
{
productId: "product-book",
productName: "Frontend Systems Handbook",
quantity: 1,
unitPriceCents: 3990,
},
],
totalCents: 3990,
currency: "USD",
createdAt: "2026-07-22T14:00:00.000Z",
},
];
this.productSequence = this.products.length + 10;
this.orderSequence = 10;
this.updateCategoryCounts();
}
findUserByEmail(email: string): SimpleUserRecord | undefined {
return this.users.find(
(user) => user.email.toLowerCase() === email.toLowerCase(),
);
}
findUserById(id: string): SimpleUserRecord | undefined {
return this.users.find((user) => user.id === id);
}
publicUser(user: SimpleUserRecord): SimpleUserDto {
const { password: _password, ...publicUser } = user;
return publicUser;
}
changeRole(userId: string, role: SimpleRole): SimpleUserDto {
const user = this.findUserById(userId);
if (!user) {
throw new NotFoundException({
code: "USER_NOT_FOUND",
message: "User not found.",
});
}
user.role = role;
return this.publicUser(user);
}
listProducts(query: ProductQueryDto): {
data: SimpleProductDto[];
meta: { page: number; limit: number; total: number; totalPages: number };
} {
let products = [...this.products];
if (query.search) {
const search = query.search.toLowerCase();
products = products.filter((product) =>
`${product.name} ${product.description}`.toLowerCase().includes(search),
);
}
if (query.categoryId) {
products = products.filter(
(product) => product.categoryId === query.categoryId,
);
}
products.sort((left, right) => {
if (query.sort === ProductSort.PriceAsc)
return left.priceCents - right.priceCents;
if (query.sort === ProductSort.PriceDesc)
return right.priceCents - left.priceCents;
if (query.sort === ProductSort.Name)
return left.name.localeCompare(right.name);
return right.createdAt.localeCompare(left.createdAt);
});
const total = products.length;
const start = (query.page - 1) * query.limit;
return {
data: products.slice(start, start + query.limit),
meta: {
page: query.page,
limit: query.limit,
total,
totalPages: Math.ceil(total / query.limit),
},
};
}
getProduct(id: string): SimpleProductDto {
const product = this.products.find((item) => item.id === id);
if (!product) {
throw new NotFoundException({
code: "PRODUCT_NOT_FOUND",
message: "Product not found.",
});
}
return product;
}
createProduct(dto: CreateProductDto): SimpleProductDto {
if (!this.categories.some((category) => category.id === dto.categoryId)) {
throw new UnprocessableEntityException({
code: "CATEGORY_NOT_FOUND",
message: "Selected category does not exist.",
details: [{ field: "categoryId", message: "Unknown category." }],
});
}
const id = `product-${this.productSequence++}`;
const product: SimpleProductDto = {
id,
name: dto.name,
slug: `${dto.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${id}`,
description: dto.description,
priceCents: dto.priceCents,
currency: dto.currency,
categoryId: dto.categoryId,
stock: dto.stock,
rating: 0,
imageUrl: dto.imageUrl,
createdAt: new Date().toISOString(),
version: 1,
};
this.products.unshift(product);
this.updateCategoryCounts();
return product;
}
updateProduct(id: string, dto: UpdateProductDto): SimpleProductDto {
const product = this.getProduct(id);
if (product.version !== dto.version) {
throw new ConflictException({
code: "PRODUCT_VERSION_CONFLICT",
message: "Product was changed by another user.",
details: [
{
field: "version",
message: `Expected ${product.version}, received ${dto.version}.`,
},
],
});
}
const { version: _version, ...changes } = dto;
Object.assign(product, changes, { version: product.version + 1 });
this.updateCategoryCounts();
return product;
}
deleteProduct(id: string): void {
this.getProduct(id);
this.products = this.products.filter((product) => product.id !== id);
this.updateCategoryCounts();
}
listCategories(): SimpleCategoryDto[] {
return this.categories;
}
getCategory(id: string): SimpleCategoryDto {
const category = this.categories.find((item) => item.id === id);
if (!category) {
throw new NotFoundException({
code: "CATEGORY_NOT_FOUND",
message: "Category not found.",
});
}
return category;
}
listOrders(userId: string, role: string, page = 1, limit = 20) {
const orders =
role === SimpleRole.Admin
? this.orders
: this.orders.filter((order) => order.userId === userId);
const total = orders.length;
const start = (page - 1) * limit;
return {
data: orders.slice(start, start + limit),
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
}
getOrder(id: string, userId: string, role: string): SimpleOrderDto {
const order = this.orders.find(
(item) =>
item.id === id && (role === SimpleRole.Admin || item.userId === userId),
);
if (!order) {
throw new NotFoundException({
code: "ORDER_NOT_FOUND",
message: "Order not found.",
});
}
return order;
}
createOrder(userId: string, dto: CreateOrderDto): SimpleOrderDto {
if (dto.items.length === 0) {
throw new UnprocessableEntityException({
code: "EMPTY_ORDER",
message: "Order must contain at least one item.",
details: [{ field: "items", message: "Add at least one item." }],
});
}
const items = dto.items.map(({ productId, quantity }) => {
const product = this.getProduct(productId);
if (product.stock < quantity) {
throw new ConflictException({
code: "INSUFFICIENT_STOCK",
message: `Not enough stock for ${product.name}.`,
});
}
return {
productId,
productName: product.name,
quantity,
unitPriceCents: product.priceCents,
};
});
const order: SimpleOrderDto = {
id: `order-${String(this.orderSequence++).padStart(3, "0")}`,
userId,
status: OrderStatus.Pending,
items,
totalCents: items.reduce(
(sum, item) => sum + item.unitPriceCents * item.quantity,
0,
),
currency: "USD",
createdAt: new Date().toISOString(),
};
this.orders.unshift(order);
return order;
}
cancelOrder(id: string, userId: string, role: string): SimpleOrderDto {
const order = this.getOrder(id, userId, role);
if (
order.status === OrderStatus.Shipped ||
order.status === OrderStatus.Cancelled
) {
throw new ConflictException({
code: "ORDER_CANNOT_BE_CANCELLED",
message: `Order in ${order.status} status cannot be cancelled.`,
});
}
order.status = OrderStatus.Cancelled;
return order;
}
private product(
id: string,
name: string,
priceCents: number,
categoryId: string,
stock: number,
rating: number,
): SimpleProductDto {
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
return {
id,
name,
slug,
description: `${name} is a deterministic demo product used by frontend examples.`,
priceCents,
currency: "USD",
categoryId,
stock,
rating,
imageUrl: `https://picsum.photos/seed/${slug}/640/480`,
createdAt: `2026-07-${String(10 + this.products.length).padStart(2, "0")}T09:00:00.000Z`,
version: 1,
};
}
private updateCategoryCounts(): void {
for (const category of this.categories) {
category.productCount = this.products.filter(
(product) => product.categoryId === category.id,
).length;
}
}
}

View File

@@ -0,0 +1,79 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Injectable,
} from "@nestjs/common";
import type { Request, Response } from "express";
import type { DemoRequest } from "./request.types";
interface HttpErrorBody {
code?: string;
message?: string | string[];
details?: unknown[];
error?: string;
}
const STATUS_CODES: Record<number, string> = {
400: "BAD_REQUEST",
401: "UNAUTHORIZED",
403: "FORBIDDEN",
404: "NOT_FOUND",
409: "CONFLICT",
413: "PAYLOAD_TOO_LARGE",
422: "UNPROCESSABLE_ENTITY",
429: "RATE_LIMITED",
500: "INTERNAL_SERVER_ERROR",
};
@Injectable()
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp();
const request = context.getRequest<Request>() as DemoRequest;
const response = context.getResponse<Response>();
const externalCode =
typeof exception === "object" && exception !== null && "code" in exception
? String((exception as { code: unknown }).code)
: undefined;
const isOversizedFile = externalCode === "LIMIT_FILE_SIZE";
const status =
exception instanceof HttpException
? exception.getStatus()
: isOversizedFile
? HttpStatus.PAYLOAD_TOO_LARGE
: HttpStatus.INTERNAL_SERVER_ERROR;
const rawBody =
exception instanceof HttpException ? exception.getResponse() : undefined;
const body: HttpErrorBody =
typeof rawBody === "object" && rawBody !== null
? (rawBody as HttpErrorBody)
: {};
const validationMessages = Array.isArray(body.message) ? body.message : [];
const publicMessage = isOversizedFile
? "Uploaded file exceeds the 5 MiB limit"
: status === 500 && !(exception instanceof HttpException)
? "Internal server error"
: Array.isArray(body.message)
? "Request validation failed"
: (body.message ??
(typeof rawBody === "string" ? rawBody : body.error) ??
"Request failed");
response.status(status).json({
statusCode: status,
code: isOversizedFile || status === HttpStatus.PAYLOAD_TOO_LARGE
? "FILE_TOO_LARGE"
: (body.code ?? STATUS_CODES[status] ?? `HTTP_${status}`),
message: publicMessage,
details:
body.details ?? validationMessages.map((message) => ({ message })),
timestamp: new Date().toISOString(),
path: request.originalUrl,
requestId: request.requestId ?? "unknown",
});
}
}

View File

@@ -0,0 +1,122 @@
import { applyDecorators } from "@nestjs/common";
import {
ApiBadRequestResponse,
ApiConflictResponse,
ApiForbiddenResponse,
ApiHeader,
ApiInternalServerErrorResponse,
ApiNotFoundResponse,
ApiResponse,
ApiTooManyRequestsResponse,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { ErrorResponseDto } from "./api.dto";
export function ApiStandardErrors(
options: { auth?: boolean; notFound?: boolean; conflict?: boolean } = {},
) {
const decorators: Array<
ClassDecorator | MethodDecorator | PropertyDecorator
> = [
ApiBadRequestResponse({
description: "Invalid request or validation error.",
type: ErrorResponseDto,
}),
ApiTooManyRequestsResponse({
description: "Demo rate limit scenario.",
type: ErrorResponseDto,
}),
ApiInternalServerErrorResponse({
description: "Unexpected or simulated server error.",
type: ErrorResponseDto,
}),
];
if (options.auth) {
decorators.push(
ApiUnauthorizedResponse({
description: "Authentication is missing or expired.",
type: ErrorResponseDto,
}),
ApiForbiddenResponse({
description: "The current user lacks permission.",
type: ErrorResponseDto,
}),
);
}
if (options.notFound) {
decorators.push(
ApiNotFoundResponse({
description: "The requested resource does not exist.",
type: ErrorResponseDto,
}),
);
}
if (options.conflict) {
decorators.push(
ApiConflictResponse({
description: "Business or optimistic-lock conflict.",
type: ErrorResponseDto,
}),
);
}
return applyDecorators(...decorators);
}
export function ApiDemoScenarioHeader() {
return ApiHeader({
name: "X-Demo-Scenario",
required: false,
enum: [
"normal",
"slow",
"timeout",
"server-error",
"rate-limited",
"empty",
"expired-auth",
"forbidden",
"conflict",
"large-dataset",
],
description:
"Forces a deterministic frontend-testing scenario for this request.",
});
}
export function ApiOrganizationHeader() {
return ApiHeader({
name: "X-Organization-Id",
required: true,
example: "org-acme",
description: "Current tenant. The authenticated user must be a member.",
});
}
export function ApiIdempotencyHeader() {
return ApiHeader({
name: "Idempotency-Key",
required: true,
example: "checkout-6c5f92ad",
description:
"Repeating a request with the same key returns the original order.",
});
}
export function ApiBinaryResponse(
description: string,
mediaType = "application/octet-stream",
) {
return ApiResponse({
status: 200,
description,
content: {
[mediaType]: {
schema: { type: "string", format: "binary" },
},
},
});
}

View File

@@ -0,0 +1,135 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class ErrorDetailDto {
@ApiPropertyOptional({ example: "email" })
field?: string;
@ApiProperty({ example: "must be an email" })
message!: string;
@ApiPropertyOptional({ example: "isEmail" })
code?: string;
}
export class ErrorResponseDto {
@ApiProperty({ example: 404 })
statusCode!: number;
@ApiProperty({ example: "PRODUCT_NOT_FOUND" })
code!: string;
@ApiProperty({ example: "Product not found" })
message!: string;
@ApiProperty({ type: [ErrorDetailDto] })
details!: ErrorDetailDto[];
@ApiProperty({ format: "date-time", example: "2026-07-30T12:00:00.000Z" })
timestamp!: string;
@ApiProperty({ example: "/api/v1/products/product-404" })
path!: string;
@ApiProperty({ example: "req-5c9f7a3d" })
requestId!: string;
}
export class PageMetaDto {
@ApiProperty({ example: 1, minimum: 1 })
page!: number;
@ApiProperty({ example: 20, minimum: 1 })
limit!: number;
@ApiProperty({ example: 48, minimum: 0 })
total!: number;
@ApiProperty({ example: 3, minimum: 0 })
totalPages!: number;
}
export class CursorMetaDto {
@ApiProperty({ example: 20, minimum: 1 })
limit!: number;
@ApiPropertyOptional({ nullable: true, example: "product-020" })
nextCursor!: string | null;
@ApiProperty({ example: true })
hasMore!: boolean;
}
export class MutationResultDto {
@ApiProperty({ example: "product-001" })
id!: string;
@ApiProperty({ example: true })
success!: boolean;
}
export class MutationResponseDto {
@ApiProperty({ type: MutationResultDto })
data!: MutationResultDto;
}
export class HealthDataDto {
@ApiProperty({ enum: ["simple", "complex"], example: "simple" })
application!: "simple" | "complex";
@ApiProperty({ enum: ["ok"], example: "ok" })
status!: "ok";
@ApiProperty({ format: "date-time" })
timestamp!: string;
@ApiProperty({ example: "1.0.0" })
version!: string;
}
export class HealthResponseDto {
@ApiProperty({ type: HealthDataDto })
data!: HealthDataDto;
}
export const DEMO_SCENARIOS = [
"normal",
"slow",
"timeout",
"server-error",
"rate-limited",
"empty",
"expired-auth",
"forbidden",
"conflict",
"large-dataset",
] as const;
export type DemoScenario = (typeof DEMO_SCENARIOS)[number];
export class ScenarioDto {
@ApiProperty({ example: "slow" })
name!: string;
@ApiProperty({
example: "Delays the response to exercise loading and cancellation states.",
})
description!: string;
}
export class ScenariosResponseDto {
@ApiProperty({ type: [ScenarioDto] })
data!: ScenarioDto[];
}
export class TestingActionDataDto {
@ApiProperty({ example: true })
success!: boolean;
@ApiProperty({ example: "State reset to the default deterministic seed." })
message!: string;
}
export class TestingActionResponseDto {
@ApiProperty({ type: TestingActionDataDto })
data!: TestingActionDataDto;
}

View File

@@ -0,0 +1,57 @@
import { randomUUID } from "node:crypto";
import { ValidationPipe } from "@nestjs/common";
import type { NestExpressApplication } from "@nestjs/platform-express";
import cookieParser from "cookie-parser";
import type { NextFunction, Request, Response } from "express";
import { ApiExceptionFilter } from "./api-exception.filter";
import { ObservabilityInterceptor } from "./observability.interceptor";
import type { DemoRequest } from "./request.types";
import { ScenarioInterceptor } from "./scenario.interceptor";
export function configureApplication(app: NestExpressApplication): void {
app.setGlobalPrefix("api/v1");
app.use(cookieParser());
app.use((request: Request, response: Response, next: NextFunction) => {
const demoRequest = request as DemoRequest;
demoRequest.requestId = String(
request.headers["x-request-id"] ?? `req-${randomUUID()}`,
);
response.setHeader("X-Request-Id", demoRequest.requestId);
next();
});
app.enableCors({
origin: true,
credentials: true,
methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"X-CSRF-Token",
"X-Demo-Scenario",
"X-Organization-Id",
"X-Request-Id",
"Idempotency-Key",
"If-None-Match",
],
exposedHeaders: [
"ETag",
"Retry-After",
"X-Request-Id",
"X-Response-Time",
"X-Demo-Scenario",
],
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
app.useGlobalFilters(app.get(ApiExceptionFilter));
app.useGlobalInterceptors(
app.get(ObservabilityInterceptor),
app.get(ScenarioInterceptor),
);
app.enableShutdownHooks();
}

View File

@@ -0,0 +1,15 @@
import { Global, Module } from "@nestjs/common";
import { ApiExceptionFilter } from "./api-exception.filter";
import { ObservabilityInterceptor } from "./observability.interceptor";
import { ScenarioInterceptor } from "./scenario.interceptor";
@Global()
@Module({
providers: [
ApiExceptionFilter,
ObservabilityInterceptor,
ScenarioInterceptor,
],
exports: [ApiExceptionFilter, ObservabilityInterceptor, ScenarioInterceptor],
})
export class InfrastructureModule {}

View File

@@ -0,0 +1,26 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from "@nestjs/common";
import type { Response } from "express";
import { Observable, tap } from "rxjs";
@Injectable()
export class ObservabilityInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const startedAt = performance.now();
const response = context.switchToHttp().getResponse<Response>();
const setTiming = () => {
if (!response.headersSent) {
response.setHeader(
"X-Response-Time",
`${Math.round(performance.now() - startedAt)}ms`,
);
}
};
return next.handle().pipe(tap({ next: setTiming, error: setTiming }));
}
}

View File

@@ -0,0 +1,71 @@
import type { INestApplication } from "@nestjs/common";
import {
DocumentBuilder,
SwaggerModule,
type OpenAPIObject,
} from "@nestjs/swagger";
interface OpenApiOptions {
kind: "simple" | "complex";
port: number;
}
export function createOpenApiDocument(
app: INestApplication,
options: OpenApiOptions,
): OpenAPIObject {
const isSimple = options.kind === "simple";
const builder = new DocumentBuilder()
.setTitle(isSimple ? "Demo Simple API" : "Demo Complex API")
.setDescription(
isSimple
? "JWT-based API for landing pages and medium frontend applications."
: "Cookie-session, multitenant and realtime API for large frontend applications.",
)
.setVersion("1.0.0")
.addServer(`http://localhost:${options.port}`, "Local development");
if (isSimple) {
builder.addBearerAuth(
{ type: "http", scheme: "bearer", bearerFormat: "JWT" },
"jwt",
);
} else {
builder
.addCookieAuth(
"demo_session",
{ type: "apiKey", in: "cookie" },
"cookieSession",
)
.addApiKey(
{
type: "apiKey",
in: "header",
name: "X-CSRF-Token",
description: "Required for authenticated mutations.",
},
"csrf",
);
}
return SwaggerModule.createDocument(app, builder.build(), {
operationIdFactory: (controllerKey, methodKey) =>
`${controllerKey.replace(/Controller$/, "")}_${methodKey}`,
});
}
export function mountOpenApi(
app: INestApplication,
document: OpenAPIObject,
title: string,
): void {
SwaggerModule.setup("docs", app, document, {
jsonDocumentUrl: "/openapi.json",
customSiteTitle: title,
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
filter: true,
},
});
}

View File

@@ -0,0 +1,14 @@
import type { Request } from "express";
export interface AuthenticatedUser {
id: string;
email: string;
name: string;
role: string;
}
export interface DemoRequest extends Request {
requestId: string;
user?: AuthenticatedUser;
organizationId?: string;
}

View File

@@ -0,0 +1,145 @@
import {
CallHandler,
ConflictException,
ExecutionContext,
ForbiddenException,
HttpException,
Injectable,
InternalServerErrorException,
NestInterceptor,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { Observable } from "rxjs";
import { delay, map } from "rxjs/operators";
import { DEMO_SCENARIOS, type DemoScenario } from "./api.dto";
function scenarioFromRequest(request: Request): DemoScenario {
const value = String(request.headers["x-demo-scenario"] ?? "normal");
return DEMO_SCENARIOS.includes(value as DemoScenario)
? (value as DemoScenario)
: "normal";
}
export function assertNoForcedAuthFailure(request: Request): void {
const scenario = scenarioFromRequest(request);
if (scenario === "expired-auth") {
throw new UnauthorizedException({
code: "DEMO_AUTH_EXPIRED",
message: "Authentication was expired by the demo scenario.",
});
}
if (scenario === "forbidden") {
throw new ForbiddenException({
code: "DEMO_FORBIDDEN",
message: "Access was denied by the demo scenario.",
});
}
}
@Injectable()
export class ScenarioInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<Request>();
const response = http.getResponse<Response>();
const scenario = scenarioFromRequest(request);
response.setHeader("X-Demo-Scenario", scenario);
if (scenario === "server-error") {
throw new InternalServerErrorException({
code: "DEMO_SERVER_ERROR",
message: "Server error forced by X-Demo-Scenario.",
});
}
if (scenario === "rate-limited") {
response.setHeader("Retry-After", "3");
throw new HttpException(
{
code: "DEMO_RATE_LIMITED",
message: "Rate limit forced by X-Demo-Scenario.",
},
429,
);
}
if (
scenario === "conflict" &&
!["GET", "HEAD", "OPTIONS"].includes(request.method)
) {
throw new ConflictException({
code: "DEMO_CONFLICT",
message: "Conflict forced by X-Demo-Scenario.",
});
}
const configuredDelay =
scenario === "slow"
? Number(process.env.MOCK_SLOW_DELAY_MS ?? 1500)
: scenario === "timeout"
? Number(process.env.MOCK_TIMEOUT_DELAY_MS ?? 30000)
: 0;
return next.handle().pipe(
configuredDelay > 0 ? delay(configuredDelay) : (source) => source,
map((payload) => this.transformPayload(payload, scenario)),
);
}
private transformPayload(payload: unknown, scenario: DemoScenario): unknown {
if (!payload || typeof payload !== "object" || !("data" in payload)) {
return payload;
}
const response = payload as {
data: unknown;
meta?: Record<string, unknown>;
};
if (!Array.isArray(response.data)) {
return payload;
}
if (scenario === "empty") {
return {
...response,
data: [],
meta: response.meta
? {
...response.meta,
total: 0,
totalPages: 0,
nextCursor: null,
hasMore: false,
}
: response.meta,
};
}
if (scenario === "large-dataset" && response.data.length > 0) {
const source = response.data as Array<Record<string, unknown>>;
const data = Array.from({ length: 250 }, (_, index) => {
const original = source[index % source.length];
return {
...original,
id: `${String(original.id ?? "item")}-scenario-${String(index + 1).padStart(3, "0")}`,
};
});
return {
...response,
data,
meta: response.meta
? {
...response.meta,
total: data.length,
totalPages: 1,
nextCursor: null,
hasMore: false,
}
: response.meta,
};
}
return payload;
}
}

View File

@@ -0,0 +1,34 @@
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { createComplexApplication } from "../apps/complex/bootstrap";
import { createSimpleApplication } from "../apps/simple/bootstrap";
async function generate(): Promise<void> {
const outputDirectory = resolve(process.cwd(), "openapi");
await mkdir(outputDirectory, { recursive: true });
const simple = await createSimpleApplication(false);
await simple.app.init();
await writeFile(
resolve(outputDirectory, "simple.json"),
`${JSON.stringify(simple.document, null, 2)}\n`,
"utf8",
);
await simple.app.close();
const complex = await createComplexApplication(false);
await complex.app.init();
await writeFile(
resolve(outputDirectory, "complex.json"),
`${JSON.stringify(complex.document, null, 2)}\n`,
"utf8",
);
await complex.app.close();
console.log("Generated openapi/simple.json and openapi/complex.json");
}
void generate().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,92 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import SwaggerParser from "@apidevtools/swagger-parser";
import type { OpenAPIObject } from "@nestjs/swagger";
interface ContractExpectation {
file: string;
requiredSecuritySchemes: string[];
requiredPath: string;
forbiddenPath: string;
}
async function validateContract(
expectation: ContractExpectation,
): Promise<void> {
const filePath = resolve(process.cwd(), "openapi", expectation.file);
await SwaggerParser.validate(filePath);
const document = JSON.parse(
await readFile(filePath, "utf8"),
) as OpenAPIObject;
const operationIds = new Set<string>();
let operationCount = 0;
for (const pathItem of Object.values(document.paths)) {
if (!pathItem) continue;
for (const method of [
"get",
"post",
"put",
"patch",
"delete",
"options",
"head",
] as const) {
const operation = pathItem[method] as
{ operationId?: string } | undefined;
if (!operation) continue;
operationCount += 1;
if (!operation.operationId)
throw new Error(
`${expectation.file}: ${method.toUpperCase()} operation has no operationId.`,
);
if (operationIds.has(operation.operationId))
throw new Error(
`${expectation.file}: duplicate operationId ${operation.operationId}.`,
);
operationIds.add(operation.operationId);
}
}
if (!document.paths[expectation.requiredPath])
throw new Error(
`${expectation.file}: required path ${expectation.requiredPath} is missing.`,
);
if (document.paths[expectation.forbiddenPath])
throw new Error(
`${expectation.file}: path ${expectation.forbiddenPath} leaked from the other application.`,
);
for (const scheme of expectation.requiredSecuritySchemes) {
if (!document.components?.securitySchemes?.[scheme])
throw new Error(
`${expectation.file}: security scheme ${scheme} is missing.`,
);
}
if (operationCount < 10)
throw new Error(
`${expectation.file}: suspiciously small contract (${operationCount} operations).`,
);
console.log(
`Validated ${expectation.file}: ${operationCount} operations, ${operationIds.size} unique operation IDs.`,
);
}
async function validate(): Promise<void> {
await validateContract({
file: "simple.json",
requiredSecuritySchemes: ["jwt"],
requiredPath: "/api/v1/products",
forbiddenPath: "/api/v1/organizations",
});
await validateContract({
file: "complex.json",
requiredSecuritySchemes: ["cookieSession", "csrf"],
requiredPath: "/api/v1/organizations",
forbiddenPath: "/api/v1/auth/refresh-token",
});
}
void validate().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,288 @@
import type { AddressInfo } from "node:net";
import type { INestApplication } from "@nestjs/common";
import { io, type Socket } from "socket.io-client";
import request from "supertest";
import { createComplexApplication } from "../src/apps/complex/bootstrap";
interface AuthenticatedAgent {
agent: ReturnType<typeof request.agent>;
csrfToken: string;
cookieHeader: string;
}
describe("Complex API", () => {
let app: INestApplication;
let baseUrl: string;
beforeAll(async () => {
const created = await createComplexApplication(false);
app = created.app;
await app.listen(0, "127.0.0.1");
const address = app.getHttpServer().address() as AddressInfo;
baseUrl = `http://127.0.0.1:${address.port}`;
});
beforeEach(async () => {
await request(app.getHttpServer())
.post("/api/v1/testing/reset")
.expect(200);
});
afterAll(async () => {
await app.close();
});
async function login(
email = "admin@complex.demo",
): Promise<AuthenticatedAgent> {
const agent = request.agent(app.getHttpServer());
const response = await agent
.post("/api/v1/auth/login")
.send({ email, password: "demo1234" })
.expect(200);
const setCookies = response.headers["set-cookie"] as unknown as string[];
return {
agent,
csrfToken: response.body.data.csrfToken as string,
cookieHeader: setCookies
.map((cookie) => cookie.split(";", 1)[0])
.join("; "),
};
}
it("serves an isolated cookie-auth OpenAPI document", async () => {
const contract = await request(app.getHttpServer())
.get("/openapi.json")
.expect(200);
expect(contract.body.info.title).toBe("Demo Complex API");
expect(
contract.body.components.securitySchemes.cookieSession,
).toBeDefined();
expect(contract.body.components.securitySchemes.csrf).toBeDefined();
expect(contract.body.paths["/api/v1/organizations"]).toBeDefined();
});
it("establishes a cookie session and enforces tenant context", async () => {
await request(app.getHttpServer()).get("/api/v1/users/me").expect(401);
const authenticated = await login();
const me = await authenticated.agent.get("/api/v1/users/me").expect(200);
expect(me.body.data.email).toBe("admin@complex.demo");
const missingTenant = await authenticated.agent
.get("/api/v1/products")
.expect(400);
expect(missingTenant.body.code).toBe("ORGANIZATION_REQUIRED");
const products = await authenticated.agent
.get("/api/v1/products")
.set("X-Organization-Id", "org-acme")
.expect(200);
expect(products.body.data[0].organizationId).toBe("org-acme");
await authenticated.agent
.get("/api/v1/products")
.set("X-Organization-Id", "org-unknown")
.expect(403);
});
it("enforces CSRF and optimistic product versions", async () => {
const authenticated = await login();
const url = "/api/v1/products/complex-product-keyboard";
await authenticated.agent
.patch(url)
.set("X-Organization-Id", "org-acme")
.send({ version: 3, name: "No CSRF" })
.expect(403);
const updated = await authenticated.agent
.patch(url)
.set("X-Organization-Id", "org-acme")
.set("X-CSRF-Token", authenticated.csrfToken)
.send({ version: 3, name: "Keyboard Enterprise" })
.expect(200);
expect(updated.body.data.version).toBe(4);
const stale = await authenticated.agent
.patch(url)
.set("X-Organization-Id", "org-acme")
.set("X-CSRF-Token", authenticated.csrfToken)
.send({ version: 3, name: "Stale Keyboard" })
.expect(409);
expect(stale.body.code).toBe("PRODUCT_VERSION_CONFLICT");
});
it("creates orders idempotently", async () => {
const authenticated = await login();
const body = {
customerId: "customer-ada",
items: [
{
productId: "complex-product-keyboard",
variantId: "variant-complex-product-keyboard-default",
quantity: 1,
},
],
promotionCode: "WELCOME10",
};
const create = () =>
authenticated.agent
.post("/api/v1/orders")
.set("X-Organization-Id", "org-acme")
.set("X-CSRF-Token", authenticated.csrfToken)
.set("Idempotency-Key", "checkout-test-001")
.send(body)
.expect(201);
const first = await create();
const second = await create();
expect(second.body.data.id).toBe(first.body.data.id);
expect(first.body.data.total.amount).toBe("116.91");
});
it("supports role changes without recreating the session", async () => {
const authenticated = await login("viewer@complex.demo");
await authenticated.agent
.post("/api/v1/products")
.set("X-Organization-Id", "org-acme")
.set("X-CSRF-Token", authenticated.csrfToken)
.send({})
.expect(403);
await request(app.getHttpServer())
.post("/api/v1/testing/users/complex-user-viewer/role")
.send({ role: "manager" })
.expect(200);
const allowedToReachValidation = await authenticated.agent
.post("/api/v1/products")
.set("X-Organization-Id", "org-acme")
.set("X-CSRF-Token", authenticated.csrfToken)
.send({})
.expect(400);
expect(allowedToReachValidation.body.code).toBe("BAD_REQUEST");
});
it("expires an active session on demand", async () => {
const authenticated = await login();
await authenticated.agent
.post("/api/v1/testing/session/expire")
.set("X-CSRF-Token", authenticated.csrfToken)
.expect(200);
await authenticated.agent.get("/api/v1/users/me").expect(401);
});
it("runs a 202 background export and exposes the completed result", async () => {
const authenticated = await login();
const started = await authenticated.agent
.post("/api/v1/exports/orders")
.set("X-Organization-Id", "org-acme")
.set("X-CSRF-Token", authenticated.csrfToken)
.expect(202);
const jobId = started.body.data.id as string;
await authenticated.agent
.get(`/api/v1/jobs/${jobId}/result`)
.set("X-Organization-Id", "org-acme")
.expect(409);
await new Promise((resolve) => setTimeout(resolve, 850));
const completed = await authenticated.agent
.get(`/api/v1/jobs/${jobId}`)
.set("X-Organization-Id", "org-acme")
.expect(200);
expect(completed.body.data).toMatchObject({
status: "completed",
progress: 100,
});
const result = await authenticated.agent
.get(`/api/v1/jobs/${jobId}/result`)
.set("X-Organization-Id", "org-acme")
.expect(200);
expect(result.headers["content-type"]).toMatch(/text\/csv/);
});
it("uploads and downloads a multipart file", async () => {
const authenticated = await login();
const uploaded = await authenticated.agent
.post("/api/v1/files")
.set("X-Organization-Id", "org-acme")
.set("X-CSRF-Token", authenticated.csrfToken)
.attach("file", Buffer.from("sku,stock\nABC,10\n"), "inventory.csv")
.expect(201);
expect(uploaded.body.data.name).toBe("inventory.csv");
const downloaded = await authenticated.agent
.get(`/api/v1/files/${uploaded.body.data.id as string}/download`)
.set("X-Organization-Id", "org-acme")
.expect(200);
expect(downloaded.headers["content-disposition"]).toContain(
"inventory.csv",
);
const tooLarge = await authenticated.agent
.post("/api/v1/files")
.set("X-Organization-Id", "org-acme")
.set("X-CSRF-Token", authenticated.csrfToken)
.attach("file", Buffer.alloc(5 * 1024 * 1024 + 1), "too-large.bin")
.expect(413);
expect(tooLarge.body.code).toBe("FILE_TOO_LARGE");
});
it("authenticates Socket.IO with the session cookie and deduplicates messages", async () => {
const authenticated = await login();
const socket = io(`${baseUrl}/chat`, {
transports: ["websocket"],
reconnection: false,
forceNew: true,
extraHeaders: { Cookie: authenticated.cookieHeader },
});
await waitForSocketEvent(socket, "connect");
const joined = waitForSocketEvent<{ conversationId: string }>(
socket,
"chat:joined",
);
socket.emit("chat:join", {
organizationId: "org-acme",
conversationId: "conversation-support",
});
expect((await joined).conversationId).toBe("conversation-support");
const messagePayload = {
organizationId: "org-acme",
conversationId: "conversation-support",
text: "Socket E2E message",
clientMessageId: "socket-e2e-001",
};
const firstAck = waitForSocketEvent<{ id: string }>(socket, "message:ack");
socket.emit("message:send", messagePayload);
const first = await firstAck;
const secondAck = waitForSocketEvent<{ id: string }>(socket, "message:ack");
socket.emit("message:send", messagePayload);
const second = await secondAck;
expect(second.id).toBe(first.id);
socket.disconnect();
}, 10_000);
});
function waitForSocketEvent<T = void>(
socket: Socket,
event: string,
timeoutMs = 3000,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
socket.off(event, handler);
reject(new Error(`Timed out waiting for Socket.IO event ${event}.`));
}, timeoutMs);
const handler = (payload: T) => {
clearTimeout(timer);
resolve(payload);
};
socket.once(event, handler);
});
}

View File

@@ -0,0 +1,159 @@
import type { INestApplication } from "@nestjs/common";
import request from "supertest";
import { createSimpleApplication } from "../src/apps/simple/bootstrap";
describe("Simple API", () => {
let app: INestApplication;
beforeAll(async () => {
const created = await createSimpleApplication(false);
app = created.app;
await app.init();
});
beforeEach(async () => {
await request(app.getHttpServer())
.post("/api/v1/testing/reset")
.expect(200);
});
afterAll(async () => {
await app.close();
});
async function login(email = "admin@demo.local") {
return request(app.getHttpServer())
.post("/api/v1/auth/login")
.send({ email, password: "demo1234" })
.expect(200);
}
it("serves health, Swagger JSON and observable headers", async () => {
const health = await request(app.getHttpServer())
.get("/api/v1/health")
.set("X-Request-Id", "frontend-request-001")
.expect(200);
expect(health.body.data).toMatchObject({
application: "simple",
status: "ok",
});
expect(health.headers["x-request-id"]).toBe("frontend-request-001");
expect(health.headers["x-response-time"]).toMatch(/^\d+ms$/);
const contract = await request(app.getHttpServer())
.get("/openapi.json")
.expect(200);
expect(contract.body.info.title).toBe("Demo Simple API");
expect(contract.body.components.securitySchemes.jwt).toBeDefined();
});
it("supports empty, large, error and rate-limit scenarios deterministically", async () => {
const empty = await request(app.getHttpServer())
.get("/api/v1/products")
.set("X-Demo-Scenario", "empty")
.expect(200);
expect(empty.body.data).toEqual([]);
expect(empty.body.meta.total).toBe(0);
const large = await request(app.getHttpServer())
.get("/api/v1/categories")
.set("X-Demo-Scenario", "large-dataset")
.expect(200);
expect(large.body.data).toHaveLength(250);
const limited = await request(app.getHttpServer())
.get("/api/v1/products")
.set("X-Demo-Scenario", "rate-limited")
.expect(429);
expect(limited.headers["retry-after"]).toBe("3");
expect(limited.body.code).toBe("DEMO_RATE_LIMITED");
const failed = await request(app.getHttpServer())
.get("/api/v1/products")
.set("X-Demo-Scenario", "server-error")
.expect(500);
expect(failed.body.code).toBe("DEMO_SERVER_ERROR");
});
it("authenticates, rotates refresh tokens and rejects token reuse", async () => {
const authenticated = await login();
const { accessToken, refreshToken } = authenticated.body.data.tokens;
const me = await request(app.getHttpServer())
.get("/api/v1/users/me")
.set("Authorization", `Bearer ${accessToken}`)
.expect(200);
expect(me.body.data.email).toBe("admin@demo.local");
await request(app.getHttpServer())
.get("/api/v1/users/me")
.set("Authorization", `Bearer ${accessToken}`)
.set("X-Demo-Scenario", "expired-auth")
.expect(401);
const rotated = await request(app.getHttpServer())
.post("/api/v1/auth/refresh")
.send({ refreshToken })
.expect(200);
expect(rotated.body.data.tokens.refreshToken).not.toBe(refreshToken);
const reused = await request(app.getHttpServer())
.post("/api/v1/auth/refresh")
.send({ refreshToken })
.expect(401);
expect(reused.body.code).toBe("REFRESH_TOKEN_REUSED");
});
it("returns 304 for a current product ETag", async () => {
const first = await request(app.getHttpServer())
.get("/api/v1/products/product-keyboard")
.expect(200);
expect(first.headers.etag).toBeDefined();
await request(app.getHttpServer())
.get("/api/v1/products/product-keyboard")
.set("If-None-Match", first.headers.etag)
.expect(304);
});
it("detects stale product updates and returns structured conflicts", async () => {
const authenticated = await login();
const accessToken = authenticated.body.data.tokens.accessToken as string;
const authorization = `Bearer ${accessToken}`;
const updated = await request(app.getHttpServer())
.patch("/api/v1/products/product-keyboard")
.set("Authorization", authorization)
.send({ version: 1, name: "Mechanical Keyboard Updated" })
.expect(200);
expect(updated.body.data.version).toBe(2);
const conflict = await request(app.getHttpServer())
.patch("/api/v1/products/product-keyboard")
.set("Authorization", authorization)
.send({ version: 1, name: "Stale Update" })
.expect(409);
expect(conflict.body.code).toBe("PRODUCT_VERSION_CONFLICT");
expect(conflict.body.requestId).toMatch(/^req-/);
});
it("enforces roles and validates nested orders", async () => {
const customer = await login("customer@demo.local");
const authorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
await request(app.getHttpServer())
.post("/api/v1/products")
.set("Authorization", authorization)
.send({})
.expect(403);
const order = await request(app.getHttpServer())
.post("/api/v1/orders")
.set("Authorization", authorization)
.send({ items: [{ productId: "product-keyboard", quantity: 1 }] })
.expect(201);
expect(order.body.data.userId).toBe("user-customer");
expect(order.body.data.totalCents).toBe(12990);
});
});

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictPropertyInitialization": false,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["node_modules", "dist"]
}