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,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;
});