mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
chore: remove example
This commit is contained in:
@@ -1,113 +0,0 @@
|
|||||||
# Шаблоны монорепозитория
|
|
||||||
|
|
||||||
Шаблоны создают повторяемый boilerplate после принятого SLM-решения. Шаблон не выбирает owner, layer, scope, public API или product data path.
|
|
||||||
|
|
||||||
Запускай генератор из корня монорепозитория, где находится `.templates`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @gromlab/create <template> <name> [path]
|
|
||||||
```
|
|
||||||
|
|
||||||
`[path]` — папка вывода относительно корня монорепозитория, например `apps/admin/src/compositions/screens`.
|
|
||||||
|
|
||||||
## Как выбирать шаблон
|
|
||||||
|
|
||||||
Сначала примени `slm-design`. Затем выбирай специализированный шаблон: `route`, `layout`, `screen`, `widget`, `business`, `business-composition` или `rest-api`.
|
|
||||||
|
|
||||||
Если сущность не попадает под один из специализированных шаблонов, используй `module`.
|
|
||||||
|
|
||||||
`module` — основной универсальный шаблон для обычных SLM-модулей и компонентов внутри существующих модулей.
|
|
||||||
|
|
||||||
## Доступные шаблоны
|
|
||||||
|
|
||||||
| Шаблон | Для чего | Куда генерировать |
|
|
||||||
|---|---|---|
|
|
||||||
| `route` | Leaf route module Next.js с обязательным business provider, одноимённым screen и public API. | `apps/web/src/compositions/routes` |
|
|
||||||
| `layout` | Layout composition с `*.layout.tsx`, стилями, props и public API. | `apps/{app}/src/compositions/layouts` |
|
|
||||||
| `screen` | Screen composition с `*.screen.tsx`, стилями, props и public API. | `apps/{app}/src/compositions/screens` |
|
|
||||||
| `widget` | Переиспользуемый composition widget, не привязанный к одной странице. | `apps/{app}/src/compositions/widgets` |
|
|
||||||
| `business` | Business-домен слоя `src/business`: factory, deps, domain error и public API. | `apps/{app}/src/business` |
|
|
||||||
| `business-composition` | Runtime-сборка business-домена: `create*Business` и public API. | `apps/{app}/src/compositions/business` |
|
|
||||||
| `rest-api` | Vite runtime REST-модуль поверх generated SDK: transport, errors и полный bound-клиент. Только для `apps/admin`. | `apps/admin/src/infra` |
|
|
||||||
| `module` | Универсальный SLM-модуль или компонент, если сущность не подходит под шаблоны выше. | `apps/{app}/src/**/parts`, `apps/{app}/src/**/ui`, локальные сегменты модулей |
|
|
||||||
|
|
||||||
## Route
|
|
||||||
|
|
||||||
`route` создаёт leaf route module Next.js в `apps/web/src/compositions/routes`.
|
|
||||||
|
|
||||||
Передавай имя без суффикса `-route`: генератор создаст папку `{name}`, файл `{name}.route.tsx`, компонент `{Name}Route` и обязательный `{Name}BusinessProvider`.
|
|
||||||
|
|
||||||
Route и одноимённый screen должны представлять одну leaf-страницу: `home` route подключает `HomeScreen`, `product` route подключает `ProductScreen`.
|
|
||||||
|
|
||||||
Route module не собирает общий layout. Общий каркас route group остаётся в соответствующем `app/**/layout.tsx` и подключает layout composition напрямую.
|
|
||||||
|
|
||||||
Business provider создаётся всегда, даже если route пока не владеет business API. До появления реального graph он передаёт точное пустое значение `value={{}}`.
|
|
||||||
|
|
||||||
После генерации добавляй в provider только реально используемые route-level business API и определяй их lifecycle внутри route scope.
|
|
||||||
|
|
||||||
Экспортируй route component через public API модуля и подключай его из соответствующего `app/**/page.tsx`.
|
|
||||||
|
|
||||||
## Module
|
|
||||||
|
|
||||||
`module` — fallback-шаблон проекта.
|
|
||||||
|
|
||||||
Используй его для:
|
|
||||||
|
|
||||||
- обычных вложенных модулей в `parts`;
|
|
||||||
- компонентов внутри `ui` родительского модуля;
|
|
||||||
- UI-модулей слоя `src/ui`;
|
|
||||||
- небольших composition-модулей, для которых нет отдельного шаблона;
|
|
||||||
- любой повторяемой сущности, которая не является `route`, `layout`, `screen`, `widget`, `business` или `business-composition`.
|
|
||||||
|
|
||||||
Шаблон создаёт корневой `.tsx`, `styles`, `types` и `index.ts`. После генерации убирай лишнее вручную, если конкретному модулю не нужны стили или props.
|
|
||||||
|
|
||||||
## Business
|
|
||||||
|
|
||||||
`business` создаёт каркас доменного модуля слоя `src/business`.
|
|
||||||
|
|
||||||
После генерации:
|
|
||||||
|
|
||||||
- не оставляй пустые `Api` и `Deps`: замени их реальными сценариями и минимальными runtime capabilities;
|
|
||||||
- добавь `services`, `hooks`, `mappers`, `lib` и доменные типы по мере появления логики;
|
|
||||||
- расширь `ERROR_CODES` конкретными кодами;
|
|
||||||
- добавь factory-level tests до завершения задачи.
|
|
||||||
|
|
||||||
## Business Composition
|
|
||||||
|
|
||||||
`business-composition` создаёт runtime-сборку business-домена для application scope.
|
|
||||||
|
|
||||||
После генерации:
|
|
||||||
|
|
||||||
- замени пустой вызов `factory({})` на явные private adapters и config;
|
|
||||||
- добавь отдельный `adapters/*` для каждой runtime capability из `Deps`;
|
|
||||||
- добавь `types/create-*-business-deps.type.ts`, если сборка зависит от другого business API;
|
|
||||||
- не экспортируй adapters через `index.ts`;
|
|
||||||
- добавь assembly tests до завершения задачи.
|
|
||||||
|
|
||||||
## REST API
|
|
||||||
|
|
||||||
`rest-api` создаёт Vite runtime infra-модуль для generated SDK `{name}-rest-api-sdk`. Шаблон не выбирает auth strategy и не предназначен для Next.js.
|
|
||||||
|
|
||||||
Передавай имя сервиса без суффикса `-rest-api`: `adp-client` создаст `src/infra/adp-client-rest-api`.
|
|
||||||
|
|
||||||
После генерации:
|
|
||||||
|
|
||||||
- настрой фактическую переменную окружения для base URL;
|
|
||||||
- добавь auth, cookies, CSRF и lifecycle только по фактическому контракту сервиса;
|
|
||||||
- экспортируй только transport API и точечные types, имеющие реальных consumers;
|
|
||||||
- подключай product operations через private adapters в `compositions/business/{domain}`;
|
|
||||||
- не используй GET-хуки напрямую из page, route, screen, widget или UI.
|
|
||||||
|
|
||||||
## Примеры
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @gromlab/create route home apps/web/src/compositions/routes
|
|
||||||
npx @gromlab/create layout main apps/admin/src/compositions/layouts
|
|
||||||
npx @gromlab/create screen main apps/admin/src/compositions/screens
|
|
||||||
npx @gromlab/create widget error-state apps/admin/src/compositions/widgets
|
|
||||||
npx @gromlab/create business user apps/admin/src/business
|
|
||||||
npx @gromlab/create business-composition user apps/admin/src/compositions/business
|
|
||||||
npx @gromlab/create rest-api backend apps/admin/src/infra
|
|
||||||
npx @gromlab/create module hero-section apps/admin/src/compositions/screens/main/parts
|
|
||||||
npx @gromlab/create module submit-button apps/admin/src/compositions/screens/main/ui
|
|
||||||
```
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { {{name.camelCase}}Factory } from '../../../business/{{name.kebabCase}}'
|
|
||||||
import type { {{name.pascalCase}}Api } from '../../../business/{{name.kebabCase}}'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создаёт {{name.pascalCase}} business API с runtime-зависимостями приложения.
|
|
||||||
*/
|
|
||||||
export const create{{name.pascalCase}}Business = (): {{name.pascalCase}}Api => {
|
|
||||||
return {{name.camelCase}}Factory({})
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { create{{name.pascalCase}}Business } from './create-{{name.kebabCase}}-business'
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { {{name.pascalCase}}ErrorCode } from '../types/{{name.kebabCase}}-error-code.type'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Доменная ошибка бизнес-модуля {{name.pascalCase}}.
|
|
||||||
*
|
|
||||||
* UI/i18n-слой должен использовать `code`, а не `message`, для выбора пользовательского текста.
|
|
||||||
*/
|
|
||||||
export class {{name.pascalCase}}BusinessError extends Error {
|
|
||||||
/** Код доменной ошибки для UI/i18n-слоя. */
|
|
||||||
readonly code: {{name.pascalCase}}ErrorCode
|
|
||||||
/** Исходная ошибка нижележащей runtime-зависимости. */
|
|
||||||
readonly cause: unknown
|
|
||||||
|
|
||||||
constructor(code: {{name.pascalCase}}ErrorCode, cause: unknown) {
|
|
||||||
super(code)
|
|
||||||
|
|
||||||
this.name = '{{name.pascalCase}}BusinessError'
|
|
||||||
this.code = code
|
|
||||||
this.cause = cause
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { {{name.camelCase}}Factory } from './{{name.kebabCase}}.factory'
|
|
||||||
export type { {{name.pascalCase}}Api } from './types/{{name.kebabCase}}-api.type'
|
|
||||||
export type { {{name.pascalCase}}Deps } from './types/{{name.kebabCase}}-deps.type'
|
|
||||||
export type { {{name.pascalCase}}Error } from './types/{{name.kebabCase}}-error.type'
|
|
||||||
export type { {{name.pascalCase}}ErrorCode } from './types/{{name.kebabCase}}-error-code.type'
|
|
||||||
export type { {{name.pascalCase}}Factory } from './types/{{name.kebabCase}}-factory.type'
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/**
|
|
||||||
* Публичный API бизнес-модуля {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}Api = object
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/**
|
|
||||||
* Runtime-зависимости бизнес-модуля {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}Deps = object
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Коды доменных ошибок бизнес-модуля {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export const {{name.screamingSnakeCase}}_ERROR_CODES = {
|
|
||||||
/** Базовая доменная ошибка бизнес-модуля {{name.pascalCase}}. */
|
|
||||||
UNKNOWN: '{{name.screamingSnakeCase}}_UNKNOWN'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Код доменной ошибки бизнес-модуля {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}ErrorCode = (typeof {{name.screamingSnakeCase}}_ERROR_CODES)[keyof typeof {{name.screamingSnakeCase}}_ERROR_CODES]
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { {{name.pascalCase}}ErrorCode } from './{{name.kebabCase}}-error-code.type'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Публичный структурный контракт доменной ошибки {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}Error = {
|
|
||||||
/** Исходная ошибка нижележащей runtime-зависимости. */
|
|
||||||
cause: unknown
|
|
||||||
/** Код доменной ошибки для UI/i18n-слоя. */
|
|
||||||
code: {{name.pascalCase}}ErrorCode
|
|
||||||
/** Сообщение ошибки, не используемое как UI-контракт. */
|
|
||||||
message: string
|
|
||||||
/** Имя доменной ошибки для диагностики. */
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { {{name.pascalCase}}Api } from './{{name.kebabCase}}-api.type'
|
|
||||||
import type { {{name.pascalCase}}Deps } from './{{name.kebabCase}}-deps.type'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Фабрика публичного API бизнес-модуля {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}Factory = (deps: {{name.pascalCase}}Deps) => {{name.pascalCase}}Api
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { {{name.pascalCase}}Factory } from './types/{{name.kebabCase}}-factory.type'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создаёт публичный API бизнес-модуля {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export const {{name.camelCase}}Factory: {{name.pascalCase}}Factory = (_deps) => {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { {{name.pascalCase}}Layout } from './{{name.kebabCase}}.layout'
|
|
||||||
export type { {{name.pascalCase}}LayoutProps } from './types/{{name.kebabCase}}-layout-props.type'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
.root {
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { ComponentPropsWithoutRef } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Параметры layout {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}LayoutParams = object
|
|
||||||
|
|
||||||
/** Атрибуты корневого элемента. */
|
|
||||||
type RootAttrs = ComponentPropsWithoutRef<'div'>
|
|
||||||
|
|
||||||
export type {{name.pascalCase}}LayoutProps = RootAttrs & {{name.pascalCase}}LayoutParams
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import cl from 'clsx'
|
|
||||||
import type { {{name.pascalCase}}LayoutProps } from './types/{{name.kebabCase}}-layout-props.type'
|
|
||||||
import styles from './styles/{{name.kebabCase}}.module.css'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <Назначение layout {{name.pascalCase}} в 1 строке>.
|
|
||||||
*
|
|
||||||
* Используется для:
|
|
||||||
* - <сценарий 1>
|
|
||||||
* - <сценарий 2>
|
|
||||||
*/
|
|
||||||
export const {{name.pascalCase}}Layout = (props: {{name.pascalCase}}LayoutProps) => {
|
|
||||||
const { children, className, ...rootAttrs } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { {{name.pascalCase}} } from './{{name.kebabCase}}'
|
|
||||||
export type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}-props.type'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
.root {
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { ComponentPropsWithoutRef } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Параметры модуля {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}Params = object
|
|
||||||
|
|
||||||
/** Атрибуты корневого элемента. */
|
|
||||||
type RootAttrs = ComponentPropsWithoutRef<'div'>
|
|
||||||
|
|
||||||
export type {{name.pascalCase}}Props = RootAttrs & {{name.pascalCase}}Params
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import cl from 'clsx'
|
|
||||||
import type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}-props.type'
|
|
||||||
import styles from './styles/{{name.kebabCase}}.module.css'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <Назначение {{name.pascalCase}} в 1 строке>.
|
|
||||||
*
|
|
||||||
* Используется для:
|
|
||||||
* - <сценарий 1>
|
|
||||||
* - <сценарий 2>
|
|
||||||
*/
|
|
||||||
export const {{name.pascalCase}} = (props: {{name.pascalCase}}Props) => {
|
|
||||||
const { children, className, ...rootAttrs } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { HttpClient } from '@biocad/{{name.kebabCase}}-rest-api-sdk'
|
|
||||||
|
|
||||||
import {
|
|
||||||
{{name.screamingSnakeCase}}_REST_API_BASE_URL,
|
|
||||||
{{name.screamingSnakeCase}}_REST_API_TIMEOUT_MS
|
|
||||||
} from './config/{{name.kebabCase}}-rest-api.config'
|
|
||||||
import { {{name.camelCase}}RestApiFetch } from './lib/{{name.kebabCase}}-rest-api-fetch'
|
|
||||||
|
|
||||||
/** Транспортный HTTP-клиент {{name.pascalCase}} REST API. */
|
|
||||||
export const {{name.camelCase}}HttpClient = new HttpClient({
|
|
||||||
baseUrl: {{name.screamingSnakeCase}}_REST_API_BASE_URL,
|
|
||||||
customFetch: {{name.camelCase}}RestApiFetch,
|
|
||||||
timeout: {{name.screamingSnakeCase}}_REST_API_TIMEOUT_MS
|
|
||||||
})
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
/** Базовый URL {{name.pascalCase}} REST API. */
|
|
||||||
export const {{name.screamingSnakeCase}}_REST_API_BASE_URL = import.meta.env.VITE_{{name.screamingSnakeCase}}_REST_API_BASE_URL ?? ''
|
|
||||||
|
|
||||||
/** Максимальная длительность REST-запроса до автоматической отмены. */
|
|
||||||
export const {{name.screamingSnakeCase}}_REST_API_TIMEOUT_MS = 30_000
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { ApiError as {{name.pascalCase}}RestApiError } from '@biocad/{{name.kebabCase}}-rest-api-sdk'
|
|
||||||
export { {{name.pascalCase}}RestApiTransportError } from './{{name.kebabCase}}-rest-api-transport.error'
|
|
||||||
export type { {{name.pascalCase}}RestApiTransportErrorCode } from './{{name.kebabCase}}-rest-api-transport.error'
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* Причина сбоя непосредственно на transport-границе {{name.pascalCase}} REST API.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}RestApiTransportErrorCode = 'NETWORK_UNAVAILABLE' | 'REQUEST_TIMEOUT'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Маркирует fetch failure, не смешивая его с TypeError из прикладного кода.
|
|
||||||
*/
|
|
||||||
export class {{name.pascalCase}}RestApiTransportError extends Error {
|
|
||||||
readonly code: {{name.pascalCase}}RestApiTransportErrorCode
|
|
||||||
readonly cause: unknown
|
|
||||||
|
|
||||||
constructor(code: {{name.pascalCase}}RestApiTransportErrorCode, cause: unknown) {
|
|
||||||
super(code)
|
|
||||||
|
|
||||||
this.name = '{{name.pascalCase}}RestApiTransportError'
|
|
||||||
this.code = code
|
|
||||||
this.cause = cause
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { {{name.camelCase}}HttpClient } from './client'
|
|
||||||
export { {{name.pascalCase}}RestApiError, {{name.pascalCase}}RestApiTransportError } from './errors'
|
|
||||||
export { {{name.camelCase}}RestApi } from './rest-api'
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { {{name.pascalCase}}RestApiTransportError } from '../errors'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Выполняет fetch с явной классификацией transport failure.
|
|
||||||
*/
|
|
||||||
export const {{name.camelCase}}RestApiFetch: typeof fetch = async (...params) => {
|
|
||||||
try {
|
|
||||||
return await fetch(...params)
|
|
||||||
} catch (error) {
|
|
||||||
const code = error instanceof DOMException && error.name === 'AbortError' ? 'REQUEST_TIMEOUT' : 'NETWORK_UNAVAILABLE'
|
|
||||||
|
|
||||||
throw new {{name.pascalCase}}RestApiTransportError(code, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createApiClient } from '@biocad/{{name.kebabCase}}-rest-api-sdk/create-api-client'
|
|
||||||
import { operationsTree } from '@biocad/{{name.kebabCase}}-rest-api-sdk/operations-tree'
|
|
||||||
|
|
||||||
import { {{name.camelCase}}HttpClient } from './client'
|
|
||||||
|
|
||||||
/** Полный bound-клиент {{name.pascalCase}} REST API. */
|
|
||||||
export const {{name.camelCase}}RestApi = createApiClient({{name.camelCase}}HttpClient, operationsTree)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { {{name.pascalCase}}Route } from './{{name.kebabCase}}.route'
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { BusinessProvider } from '@/infra/business'
|
|
||||||
import type { {{name.pascalCase}}BusinessProviderProps } from '../types/{{name.kebabCase}}-business-provider-props.type'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route-level provider бизнес-модулей для {{name.pascalCase}} route.
|
|
||||||
*
|
|
||||||
* Используется для:
|
|
||||||
* - владения business API в lifecycle route-ветки
|
|
||||||
*/
|
|
||||||
export const {{name.pascalCase}}BusinessProvider = (props: {{name.pascalCase}}BusinessProviderProps) => {
|
|
||||||
const { children } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BusinessProvider value={{}}>
|
|
||||||
{children}
|
|
||||||
</BusinessProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Параметры {{name.pascalCase}}BusinessProvider.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}BusinessProviderProps = {
|
|
||||||
/** Дочернее дерево {{name.pascalCase}} route. */
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { {{name.pascalCase}}Screen } from '@/compositions/screens/{{name.kebabCase}}'
|
|
||||||
import { {{name.pascalCase}}BusinessProvider } from './providers/{{name.kebabCase}}-business.provider'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route module страницы {{name.pascalCase}}.
|
|
||||||
*
|
|
||||||
* Используется для:
|
|
||||||
* - сборки route-level business graph и screen страницы
|
|
||||||
*/
|
|
||||||
export const {{name.pascalCase}}Route = () => {
|
|
||||||
return (
|
|
||||||
<{{name.pascalCase}}BusinessProvider>
|
|
||||||
<{{name.pascalCase}}Screen />
|
|
||||||
</{{name.pascalCase}}BusinessProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { {{name.pascalCase}}Screen } from './{{name.kebabCase}}.screen'
|
|
||||||
export type { {{name.pascalCase}}ScreenProps } from './types/{{name.kebabCase}}-screen-props.type'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
.root {
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { ComponentPropsWithoutRef } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Параметры экрана {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}ScreenParams = object
|
|
||||||
|
|
||||||
/** Атрибуты корневого элемента. */
|
|
||||||
type RootAttrs = ComponentPropsWithoutRef<'main'>
|
|
||||||
|
|
||||||
export type {{name.pascalCase}}ScreenProps = RootAttrs & {{name.pascalCase}}ScreenParams
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import cl from 'clsx'
|
|
||||||
import type { {{name.pascalCase}}ScreenProps } from './types/{{name.kebabCase}}-screen-props.type'
|
|
||||||
import styles from './styles/{{name.kebabCase}}.module.css'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <Назначение экрана {{name.pascalCase}} в 1 строке>.
|
|
||||||
*
|
|
||||||
* Используется для:
|
|
||||||
* - <сценарий 1>
|
|
||||||
* - <сценарий 2>
|
|
||||||
*/
|
|
||||||
export const {{name.pascalCase}}Screen = (props: {{name.pascalCase}}ScreenProps) => {
|
|
||||||
const { children, className, ...rootAttrs } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { {{name.pascalCase}}Widget } from './{{name.kebabCase}}.widget'
|
|
||||||
export type { {{name.pascalCase}}WidgetProps } from './types/{{name.kebabCase}}-widget-props.type'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
.root {
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { ComponentPropsWithoutRef } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Параметры виджета {{name.pascalCase}}.
|
|
||||||
*/
|
|
||||||
export type {{name.pascalCase}}WidgetParams = object
|
|
||||||
|
|
||||||
/** Атрибуты корневого элемента. */
|
|
||||||
type RootAttrs = ComponentPropsWithoutRef<'div'>
|
|
||||||
|
|
||||||
export type {{name.pascalCase}}WidgetProps = RootAttrs & {{name.pascalCase}}WidgetParams
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import cl from 'clsx'
|
|
||||||
import type { {{name.pascalCase}}WidgetProps } from './types/{{name.kebabCase}}-widget-props.type'
|
|
||||||
import styles from './styles/{{name.kebabCase}}.module.css'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <Назначение виджета {{name.pascalCase}} в 1 строке>.
|
|
||||||
*
|
|
||||||
* Используется для:
|
|
||||||
* - <сценарий 1>
|
|
||||||
* - <сценарий 2>
|
|
||||||
*/
|
|
||||||
export const {{name.pascalCase}}Widget = (props: {{name.pascalCase}}WidgetProps) => {
|
|
||||||
const { children, className, ...rootAttrs } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
SIMPLE_PORT=3001
|
|
||||||
COMPLEX_PORT=3002
|
|
||||||
JWT_ACCESS_SECRET=demo-access-secret-change-me
|
|
||||||
JWT_REFRESH_SECRET=demo-refresh-secret-change-me
|
|
||||||
JWT_ACCESS_TTL=60s
|
|
||||||
JWT_REFRESH_TTL=7d
|
|
||||||
COOKIE_SESSION_TTL_MS=1800000
|
|
||||||
COOKIE_SECURE=false
|
|
||||||
MOCK_SLOW_DELAY_MS=1500
|
|
||||||
MOCK_TIMEOUT_DELAY_MS=30000
|
|
||||||
6
examples/demo-backend/.gitignore
vendored
6
examples/demo-backend/.gitignore
vendored
@@ -1,6 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
coverage/
|
|
||||||
.env
|
|
||||||
npm-debug.log*
|
|
||||||
*.tsbuildinfo
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# Demo backend
|
|
||||||
|
|
||||||
An isolated NestJS package containing two independently launched HTTP applications and two OpenAPI contracts. Data is deterministic and stored in memory; no database or external service is required.
|
|
||||||
|
|
||||||
## Applications
|
|
||||||
|
|
||||||
| Application | Port | Authentication | Swagger UI | OpenAPI JSON |
|
|
||||||
| ----------- | -----: | ------------------------------ | ---------------------------- | ------------------------------------ |
|
|
||||||
| Simple | `3001` | JWT access/refresh | `http://localhost:3001/docs` | `http://localhost:3001/openapi.json` |
|
|
||||||
| Complex | `3002` | HttpOnly cookie session + CSRF | `http://localhost:3002/docs` | `http://localhost:3002/openapi.json` |
|
|
||||||
|
|
||||||
Committed specifications are generated at `openapi/simple.json` and `openapi/complex.json`.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Node.js 20 or newer
|
|
||||||
- npm 10 or newer
|
|
||||||
|
|
||||||
## Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run start:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Run only one application:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev:simple
|
|
||||||
npm run dev:complex
|
|
||||||
```
|
|
||||||
|
|
||||||
Production-style build and start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
npm run start:simple
|
|
||||||
npm run start:complex
|
|
||||||
```
|
|
||||||
|
|
||||||
## Demo users
|
|
||||||
|
|
||||||
All passwords are `demo1234`.
|
|
||||||
|
|
||||||
### Simple API
|
|
||||||
|
|
||||||
| Email | Role |
|
|
||||||
| --------------------- | -------- |
|
|
||||||
| `admin@demo.local` | admin |
|
|
||||||
| `customer@demo.local` | customer |
|
|
||||||
|
|
||||||
### Complex API
|
|
||||||
|
|
||||||
| Email | Role | Organizations |
|
|
||||||
| ---------------------- | ------- | ------------------------ |
|
|
||||||
| `admin@complex.demo` | admin | `org-acme`, `org-globex` |
|
|
||||||
| `manager@complex.demo` | manager | `org-acme` |
|
|
||||||
| `support@complex.demo` | support | `org-acme` |
|
|
||||||
| `viewer@complex.demo` | viewer | `org-acme` |
|
|
||||||
|
|
||||||
## Simple authentication example
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s http://localhost:3001/api/v1/auth/login \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"email":"admin@demo.local","password":"demo1234"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `data.tokens.accessToken` as a Bearer token.
|
|
||||||
|
|
||||||
## Complex authentication example
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -i -c /tmp/demo-cookies.txt http://localhost:3002/api/v1/auth/login \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"email":"admin@complex.demo","password":"demo1234"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
The response body contains `data.csrfToken`. Protected reads require cookies and tenant context:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -b /tmp/demo-cookies.txt http://localhost:3002/api/v1/products \
|
|
||||||
-H 'X-Organization-Id: org-acme'
|
|
||||||
```
|
|
||||||
|
|
||||||
Mutations also require `X-CSRF-Token` with the value returned by login.
|
|
||||||
|
|
||||||
## OpenAPI
|
|
||||||
|
|
||||||
Generate and validate both specifications:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run openapi:generate
|
|
||||||
npm run openapi:validate
|
|
||||||
```
|
|
||||||
|
|
||||||
The validator checks OpenAPI validity, unique operation IDs, expected security schemes and route isolation between the two applications.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run typecheck
|
|
||||||
npm run build
|
|
||||||
npm run test:e2e
|
|
||||||
npm run openapi:generate
|
|
||||||
npm run openapi:validate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Built-in frontend cases
|
|
||||||
|
|
||||||
The intentionally supported cases are documented in [docs/CASES.md](./docs/CASES.md). They include controlled latency and errors, JWT refresh races, cookie expiration, CSRF, RBAC, tenant switching, offset and cursor pagination, ETag, optimistic locking, idempotency, background jobs, multipart files, polymorphic DTOs, audit events and realtime reconnection/deduplication.
|
|
||||||
|
|
||||||
The Socket.IO event contract is documented separately in [docs/WEBSOCKET.md](./docs/WEBSOCKET.md).
|
|
||||||
|
|
||||||
## Important limitation
|
|
||||||
|
|
||||||
This is a frontend architecture fixture, not a production identity or commerce service. Passwords, sessions, files and mutations live only in process memory. Restart or `POST /api/v1/testing/reset` restores deterministic data.
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# Frontend architecture cases
|
|
||||||
|
|
||||||
This document is the contract for behaviors intentionally built into the demo backend. The APIs are deterministic: reset state before a demo with `POST /api/v1/testing/reset`.
|
|
||||||
|
|
||||||
## Project-size mapping
|
|
||||||
|
|
||||||
| Frontend type | Recommended API surface |
|
|
||||||
| -------------------- | ------------------------------------------------------------------------------------ |
|
|
||||||
| Landing or small SPA | Public Simple products and categories |
|
|
||||||
| Medium application | Full Simple API with JWT, user profile and orders |
|
|
||||||
| Large application | Complex API with cookie session, tenant context, RBAC, jobs, files and realtime chat |
|
|
||||||
|
|
||||||
## Controlled network scenarios
|
|
||||||
|
|
||||||
Send `X-Demo-Scenario` with any HTTP request. The response repeats the selected value in `X-Demo-Scenario`.
|
|
||||||
|
|
||||||
| Value | Behavior | Frontend concern |
|
|
||||||
| --------------- | --------------------------------------------- | --------------------------------------- |
|
|
||||||
| `normal` | Normal deterministic response | Happy path |
|
|
||||||
| `slow` | Delays for `MOCK_SLOW_DELAY_MS` | Loading states, cancellation, skeletons |
|
|
||||||
| `timeout` | Delays for `MOCK_TIMEOUT_DELAY_MS` | Client timeout and abort handling |
|
|
||||||
| `server-error` | Returns `500` | Error boundaries and retry UX |
|
|
||||||
| `rate-limited` | Returns `429` and `Retry-After: 3` | Backoff and retry policy |
|
|
||||||
| `empty` | Changes a list response to a valid empty page | Empty states |
|
|
||||||
| `expired-auth` | Protected endpoint returns `401` | Refresh or re-login flow |
|
|
||||||
| `forbidden` | Protected endpoint returns `403` | Permission UI |
|
|
||||||
| `conflict` | Mutation returns `409` | Conflict UX and rollback |
|
|
||||||
| `large-dataset` | Expands a list response to 250 items | Rendering and virtualization |
|
|
||||||
|
|
||||||
Scenario effects are request-local. They do not introduce random failures or make automated tests flaky.
|
|
||||||
|
|
||||||
## Authentication cases
|
|
||||||
|
|
||||||
### Simple JWT
|
|
||||||
|
|
||||||
- Access and refresh tokens are returned by `POST /api/v1/auth/login`.
|
|
||||||
- Access tokens are sent as `Authorization: Bearer <token>`.
|
|
||||||
- Refresh tokens rotate. Reusing an already rotated refresh token returns `401 REFRESH_TOKEN_REUSED`.
|
|
||||||
- Logout revokes the supplied refresh token and is idempotent.
|
|
||||||
- Access and refresh lifetimes are configurable through environment variables.
|
|
||||||
- Protected requests support forced `401` and `403` scenarios.
|
|
||||||
|
|
||||||
### Complex cookie session
|
|
||||||
|
|
||||||
- Login sets HttpOnly `demo_session` and readable `demo_csrf` cookies.
|
|
||||||
- Browser requests must use `credentials: 'include'`.
|
|
||||||
- Mutations additionally send the `demo_csrf` value in `X-CSRF-Token`.
|
|
||||||
- Session refresh rotates both the session ID and CSRF token.
|
|
||||||
- `POST /api/v1/testing/session/expire` expires the current session without changing frontend state.
|
|
||||||
- Roles can change while the session remains active.
|
|
||||||
- Socket.IO authenticates using the same `demo_session` cookie.
|
|
||||||
|
|
||||||
## Data fetching cases
|
|
||||||
|
|
||||||
| Case | Endpoint example |
|
|
||||||
| -------------------- | ------------------------------------------------------ |
|
|
||||||
| Offset pagination | Simple products, customers, audit events |
|
|
||||||
| Cursor pagination | Complex products, orders, notifications, chat messages |
|
|
||||||
| Search and filtering | Products and customers |
|
|
||||||
| Sorting | Simple products |
|
|
||||||
| Nested resources | Organization members and conversation messages |
|
|
||||||
| Nullable values | Avatar, category parent, publish date, file/job result |
|
|
||||||
| Decimal strings | Complex prices, discounts and totals |
|
|
||||||
| Polymorphic union | Order, inventory and system notification payloads |
|
|
||||||
| Tree data | Complex categories with parent/child IDs |
|
|
||||||
| Large seed | `POST /api/v1/testing/seed/large` |
|
|
||||||
|
|
||||||
## Cache case
|
|
||||||
|
|
||||||
Product details return a weak `ETag`. Repeat the request with `If-None-Match`; unchanged data returns `304 Not Modified`. Product updates increment `version` and produce a new ETag.
|
|
||||||
|
|
||||||
## Mutation and concurrency cases
|
|
||||||
|
|
||||||
- Simple and Complex product updates require the last-read `version`.
|
|
||||||
- Inventory adjustments require `version` and reject negative stock.
|
|
||||||
- Stale writes return `409` with a stable error code.
|
|
||||||
- Complex order creation requires `Idempotency-Key`.
|
|
||||||
- Repeating the same order request and key returns the original order instead of creating a duplicate.
|
|
||||||
- Invalid nested forms return field-oriented validation details.
|
|
||||||
- Order cancellation validates allowed state transitions.
|
|
||||||
- Mutations are added to the Complex audit log.
|
|
||||||
|
|
||||||
## RBAC and multitenancy cases
|
|
||||||
|
|
||||||
- Complex domain routes require `X-Organization-Id`.
|
|
||||||
- Missing tenant context returns `400 ORGANIZATION_REQUIRED`.
|
|
||||||
- A tenant unavailable to the current user returns `403 ORGANIZATION_FORBIDDEN`.
|
|
||||||
- Admin and manager can mutate catalog/inventory and start exports.
|
|
||||||
- Support can create and cancel orders but cannot change products.
|
|
||||||
- Viewer is read-only.
|
|
||||||
- Test users and memberships are documented in the main README.
|
|
||||||
|
|
||||||
## Background work
|
|
||||||
|
|
||||||
`POST /api/v1/exports/orders` returns `202` and a job. Poll `GET /api/v1/jobs/:id` to observe:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pending -> processing -> completed
|
|
||||||
```
|
|
||||||
|
|
||||||
Progress and `resultUrl` change over time. Downloading the result before completion returns `409 JOB_NOT_COMPLETED`.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- Multipart upload accepts a `file` field up to 5 MiB.
|
|
||||||
- Metadata and bytes remain in memory until reset or restart.
|
|
||||||
- Download returns binary content and `Content-Disposition`.
|
|
||||||
- The API supports missing-file validation, loading progress and cancellation testing.
|
|
||||||
|
|
||||||
## Realtime chat
|
|
||||||
|
|
||||||
- REST supplies conversation and cursor-paginated message history.
|
|
||||||
- Socket.IO supplies joins, typing state and messages.
|
|
||||||
- `clientMessageId` deduplicates retried sends.
|
|
||||||
- Invalid or expired cookie sessions are disconnected.
|
|
||||||
- REST-created messages are also broadcast to the Socket.IO room.
|
|
||||||
- The complete event contract is in [WEBSOCKET.md](./WEBSOCKET.md).
|
|
||||||
|
|
||||||
## Observability and errors
|
|
||||||
|
|
||||||
Every HTTP response exposes `X-Request-Id`, `X-Response-Time` and `X-Demo-Scenario`. A frontend can supply its own `X-Request-Id`. Error bodies include that request ID, a stable machine-readable `code`, human-readable `message`, field `details`, timestamp and path.
|
|
||||||
|
|
||||||
## Reset behavior
|
|
||||||
|
|
||||||
| Endpoint | Effect |
|
|
||||||
| ------------------------------------- | ------------------------------------- |
|
|
||||||
| `POST /api/v1/testing/reset` | Restores the small deterministic seed |
|
|
||||||
| `POST /api/v1/testing/seed/small` | Loads normal fixtures |
|
|
||||||
| `POST /api/v1/testing/seed/large` | Loads 250 products and customers |
|
|
||||||
| `POST /api/v1/testing/users/:id/role` | Changes access without a new login |
|
|
||||||
| `POST /api/v1/testing/session/expire` | Expires the current Complex session |
|
|
||||||
|
|
||||||
All mutations are process-local and disappear after restart.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Socket.IO chat contract
|
|
||||||
|
|
||||||
## Connection
|
|
||||||
|
|
||||||
Connect to the Complex API namespace:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://localhost:3002/chat
|
|
||||||
```
|
|
||||||
|
|
||||||
The browser must already have a valid `demo_session` cookie and use credentials. Both `websocket` and `polling` transports are enabled.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { io } from "socket.io-client";
|
|
||||||
|
|
||||||
const socket = io("http://localhost:3002/chat", {
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
An invalid session receives `chat:error` and is disconnected.
|
|
||||||
|
|
||||||
## Client events
|
|
||||||
|
|
||||||
### `chat:join`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"organizationId": "org-acme",
|
|
||||||
"conversationId": "conversation-support"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response event: `chat:joined`.
|
|
||||||
|
|
||||||
### `chat:leave`
|
|
||||||
|
|
||||||
Uses the same payload. Response event: `chat:left`.
|
|
||||||
|
|
||||||
### `message:send`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"organizationId": "org-acme",
|
|
||||||
"conversationId": "conversation-support",
|
|
||||||
"text": "Can you check this order?",
|
|
||||||
"clientMessageId": "frontend-generated-uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response event: `message:ack`. Room broadcast: `message:created`.
|
|
||||||
|
|
||||||
Sending the same `clientMessageId` again returns the existing message. Frontends should also deduplicate incoming `message:created` by server message `id`.
|
|
||||||
|
|
||||||
### `typing:start` and `typing:stop`
|
|
||||||
|
|
||||||
Use the `chat:join` payload. Other room members receive `typing:started` or `typing:stopped`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"conversationId": "conversation-support",
|
|
||||||
"userId": "complex-user-support"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error event
|
|
||||||
|
|
||||||
`chat:error` always contains a stable shape:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": "CHAT_OPERATION_FAILED",
|
|
||||||
"message": "Conversation not found."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reconnect expectations
|
|
||||||
|
|
||||||
After reconnect, the frontend should join active conversations again and reload messages after its last known cursor. A session expired by `POST /api/v1/testing/session/expire` rejects the next Socket.IO connection.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
|
||||||
"collection": "@nestjs/schematics",
|
|
||||||
"sourceRoot": "src",
|
|
||||||
"compilerOptions": {
|
|
||||||
"deleteOutDir": false,
|
|
||||||
"tsConfigPath": "tsconfig.build.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
8654
examples/demo-backend/package-lock.json
generated
8654
examples/demo-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "demo-backend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "Two isolated NestJS demo APIs for testing frontend application architecture",
|
|
||||||
"license": "UNLICENSED",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc -p tsconfig.build.json",
|
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
||||||
"start:dev": "concurrently -k -n simple,complex -c blue,magenta \"npm:dev:simple\" \"npm:dev:complex\"",
|
|
||||||
"dev:simple": "nest start --watch --path tsconfig.build.json --entryFile apps/simple/main",
|
|
||||||
"dev:complex": "nest start --watch --path tsconfig.build.json --entryFile apps/complex/main",
|
|
||||||
"start:simple": "node dist/apps/simple/main.js",
|
|
||||||
"start:complex": "node dist/apps/complex/main.js",
|
|
||||||
"openapi:generate": "npm run build && node dist/scripts/generate-openapi.js",
|
|
||||||
"openapi:validate": "node dist/scripts/validate-openapi.js",
|
|
||||||
"test": "jest",
|
|
||||||
"test:e2e": "jest --runInBand",
|
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"*.{json,md}\" \"docs/**/*.md\""
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nestjs/common": "^11.1.6",
|
|
||||||
"@nestjs/core": "^11.1.6",
|
|
||||||
"@nestjs/jwt": "^11.0.1",
|
|
||||||
"@nestjs/platform-express": "^11.1.6",
|
|
||||||
"@nestjs/platform-socket.io": "^11.1.6",
|
|
||||||
"@nestjs/swagger": "^11.2.0",
|
|
||||||
"@nestjs/websockets": "^11.1.6",
|
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"class-validator": "^0.14.2",
|
|
||||||
"cookie": "^1.0.2",
|
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"reflect-metadata": "^0.2.2",
|
|
||||||
"rxjs": "^7.8.2",
|
|
||||||
"socket.io": "^4.8.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@apidevtools/swagger-parser": "^12.0.0",
|
|
||||||
"@nestjs/cli": "^11.0.10",
|
|
||||||
"@nestjs/testing": "^11.1.6",
|
|
||||||
"@types/cookie-parser": "^1.4.9",
|
|
||||||
"@types/express": "^5.0.3",
|
|
||||||
"@types/jest": "^29.5.14",
|
|
||||||
"@types/multer": "^2.0.0",
|
|
||||||
"@types/node": "^24.0.15",
|
|
||||||
"@types/supertest": "^6.0.3",
|
|
||||||
"concurrently": "^9.2.0",
|
|
||||||
"jest": "^29.7.0",
|
|
||||||
"prettier": "^3.6.2",
|
|
||||||
"socket.io-client": "^4.8.1",
|
|
||||||
"supertest": "^7.1.4",
|
|
||||||
"ts-jest": "^29.4.0",
|
|
||||||
"typescript": "^5.9.2"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@nestjs/swagger": {
|
|
||||||
"js-yaml": "5.2.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"moduleFileExtensions": [
|
|
||||||
"js",
|
|
||||||
"json",
|
|
||||||
"ts"
|
|
||||||
],
|
|
||||||
"rootDir": ".",
|
|
||||||
"testRegex": ".*\\.e2e-spec\\.ts$",
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(t|j)s$": [
|
|
||||||
"ts-jest",
|
|
||||||
{
|
|
||||||
"tsconfig": "tsconfig.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"collectCoverageFrom": [
|
|
||||||
"src/**/*.(t|j)s"
|
|
||||||
],
|
|
||||||
"coverageDirectory": "coverage",
|
|
||||||
"testEnvironment": "node"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
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.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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
@@ -1,217 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
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) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
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) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
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, unprocessable: 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) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
import { Type } from "class-transformer";
|
|
||||||
import {
|
|
||||||
IsArray,
|
|
||||||
IsEmail,
|
|
||||||
IsEnum,
|
|
||||||
IsInt,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Max,
|
|
||||||
MaxLength,
|
|
||||||
Matches,
|
|
||||||
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()
|
|
||||||
@Matches(/^https:\/\/picsum\.photos\//, {
|
|
||||||
message: "imageUrl must use the https://picsum.photos host",
|
|
||||||
})
|
|
||||||
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;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 1, minimum: 1 })
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
expectedVersion!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 12990, minimum: 0 })
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
expectedUnitPriceCents!: 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;
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
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 productIds = new Set<string>();
|
|
||||||
|
|
||||||
dto.items.forEach((item) => {
|
|
||||||
if (productIds.has(item.productId)) {
|
|
||||||
throw new UnprocessableEntityException({
|
|
||||||
code: "DUPLICATE_ORDER_PRODUCT",
|
|
||||||
message: "Each product may appear only once in an order.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
productIds.add(item.productId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = dto.items.map(
|
|
||||||
({ productId, quantity, expectedVersion, expectedUnitPriceCents }) => {
|
|
||||||
const product = this.getProduct(productId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
product.version !== expectedVersion ||
|
|
||||||
product.priceCents !== expectedUnitPriceCents
|
|
||||||
) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: "PRODUCT_CHANGED",
|
|
||||||
message: `${product.name} changed before checkout.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (product.currency !== "USD") {
|
|
||||||
throw new UnprocessableEntityException({
|
|
||||||
code: "UNSUPPORTED_ORDER_CURRENCY",
|
|
||||||
message: "Simple API checkout accepts USD products only.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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-10T09: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { applyDecorators } from "@nestjs/common";
|
|
||||||
import {
|
|
||||||
ApiBadRequestResponse,
|
|
||||||
ApiConflictResponse,
|
|
||||||
ApiForbiddenResponse,
|
|
||||||
ApiHeader,
|
|
||||||
ApiInternalServerErrorResponse,
|
|
||||||
ApiNotFoundResponse,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTooManyRequestsResponse,
|
|
||||||
ApiUnauthorizedResponse,
|
|
||||||
ApiUnprocessableEntityResponse,
|
|
||||||
} from "@nestjs/swagger";
|
|
||||||
import { ErrorResponseDto } from "./api.dto";
|
|
||||||
|
|
||||||
export function ApiStandardErrors(
|
|
||||||
options: {
|
|
||||||
auth?: boolean;
|
|
||||||
notFound?: boolean;
|
|
||||||
conflict?: boolean;
|
|
||||||
unprocessable?: 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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.unprocessable) {
|
|
||||||
decorators.push(
|
|
||||||
ApiUnprocessableEntityResponse({
|
|
||||||
description: "The validated request violates an order invariant.",
|
|
||||||
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" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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 }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import {
|
|
||||||
CallHandler,
|
|
||||||
ConflictException,
|
|
||||||
ExecutionContext,
|
|
||||||
ForbiddenException,
|
|
||||||
HttpException,
|
|
||||||
Injectable,
|
|
||||||
InternalServerErrorException,
|
|
||||||
NestInterceptor,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import type { Request, Response } from "express";
|
|
||||||
import { fromEvent, Observable, timer } from "rxjs";
|
|
||||||
import { delay, map, mergeMap, takeUntil } 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;
|
|
||||||
const isMutation = !["GET", "HEAD", "OPTIONS"].includes(request.method);
|
|
||||||
const source =
|
|
||||||
configuredDelay > 0 && isMutation
|
|
||||||
? timer(configuredDelay).pipe(
|
|
||||||
takeUntil(fromEvent(response, "close")),
|
|
||||||
mergeMap(() => next.handle()),
|
|
||||||
)
|
|
||||||
: next
|
|
||||||
.handle()
|
|
||||||
.pipe(
|
|
||||||
configuredDelay > 0 ? delay(configuredDelay) : (value) => value,
|
|
||||||
);
|
|
||||||
|
|
||||||
return source.pipe(
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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;
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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;
|
|
||||||
});
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
import type { AddressInfo } from "node:net";
|
|
||||||
import type { INestApplication } from "@nestjs/common";
|
|
||||||
import { io, type Socket } from "socket.io-client";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createComplexApplication } from "../src/apps/complex/bootstrap";
|
|
||||||
|
|
||||||
interface AuthenticatedAgent {
|
|
||||||
agent: ReturnType<typeof request.agent>;
|
|
||||||
csrfToken: string;
|
|
||||||
cookieHeader: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Complex API", () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let baseUrl: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const created = await createComplexApplication(false);
|
|
||||||
app = created.app;
|
|
||||||
await app.listen(0, "127.0.0.1");
|
|
||||||
const address = app.getHttpServer().address() as AddressInfo;
|
|
||||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/testing/reset")
|
|
||||||
.expect(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function login(
|
|
||||||
email = "admin@complex.demo",
|
|
||||||
): Promise<AuthenticatedAgent> {
|
|
||||||
const agent = request.agent(app.getHttpServer());
|
|
||||||
const response = await agent
|
|
||||||
.post("/api/v1/auth/login")
|
|
||||||
.send({ email, password: "demo1234" })
|
|
||||||
.expect(200);
|
|
||||||
const setCookies = response.headers["set-cookie"] as unknown as string[];
|
|
||||||
return {
|
|
||||||
agent,
|
|
||||||
csrfToken: response.body.data.csrfToken as string,
|
|
||||||
cookieHeader: setCookies
|
|
||||||
.map((cookie) => cookie.split(";", 1)[0])
|
|
||||||
.join("; "),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it("serves an isolated cookie-auth OpenAPI document", async () => {
|
|
||||||
const contract = await request(app.getHttpServer())
|
|
||||||
.get("/openapi.json")
|
|
||||||
.expect(200);
|
|
||||||
expect(contract.body.info.title).toBe("Demo Complex API");
|
|
||||||
expect(
|
|
||||||
contract.body.components.securitySchemes.cookieSession,
|
|
||||||
).toBeDefined();
|
|
||||||
expect(contract.body.components.securitySchemes.csrf).toBeDefined();
|
|
||||||
expect(contract.body.paths["/api/v1/organizations"]).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("establishes a cookie session and enforces tenant context", async () => {
|
|
||||||
await request(app.getHttpServer()).get("/api/v1/users/me").expect(401);
|
|
||||||
const authenticated = await login();
|
|
||||||
|
|
||||||
const me = await authenticated.agent.get("/api/v1/users/me").expect(200);
|
|
||||||
expect(me.body.data.email).toBe("admin@complex.demo");
|
|
||||||
|
|
||||||
const missingTenant = await authenticated.agent
|
|
||||||
.get("/api/v1/products")
|
|
||||||
.expect(400);
|
|
||||||
expect(missingTenant.body.code).toBe("ORGANIZATION_REQUIRED");
|
|
||||||
|
|
||||||
const products = await authenticated.agent
|
|
||||||
.get("/api/v1/products")
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.expect(200);
|
|
||||||
expect(products.body.data[0].organizationId).toBe("org-acme");
|
|
||||||
|
|
||||||
await authenticated.agent
|
|
||||||
.get("/api/v1/products")
|
|
||||||
.set("X-Organization-Id", "org-unknown")
|
|
||||||
.expect(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enforces CSRF and optimistic product versions", async () => {
|
|
||||||
const authenticated = await login();
|
|
||||||
const url = "/api/v1/products/complex-product-keyboard";
|
|
||||||
|
|
||||||
await authenticated.agent
|
|
||||||
.patch(url)
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.send({ version: 3, name: "No CSRF" })
|
|
||||||
.expect(403);
|
|
||||||
|
|
||||||
const updated = await authenticated.agent
|
|
||||||
.patch(url)
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.set("X-CSRF-Token", authenticated.csrfToken)
|
|
||||||
.send({ version: 3, name: "Keyboard Enterprise" })
|
|
||||||
.expect(200);
|
|
||||||
expect(updated.body.data.version).toBe(4);
|
|
||||||
|
|
||||||
const stale = await authenticated.agent
|
|
||||||
.patch(url)
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.set("X-CSRF-Token", authenticated.csrfToken)
|
|
||||||
.send({ version: 3, name: "Stale Keyboard" })
|
|
||||||
.expect(409);
|
|
||||||
expect(stale.body.code).toBe("PRODUCT_VERSION_CONFLICT");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates orders idempotently", async () => {
|
|
||||||
const authenticated = await login();
|
|
||||||
const body = {
|
|
||||||
customerId: "customer-ada",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
productId: "complex-product-keyboard",
|
|
||||||
variantId: "variant-complex-product-keyboard-default",
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
promotionCode: "WELCOME10",
|
|
||||||
};
|
|
||||||
const create = () =>
|
|
||||||
authenticated.agent
|
|
||||||
.post("/api/v1/orders")
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.set("X-CSRF-Token", authenticated.csrfToken)
|
|
||||||
.set("Idempotency-Key", "checkout-test-001")
|
|
||||||
.send(body)
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
const first = await create();
|
|
||||||
const second = await create();
|
|
||||||
expect(second.body.data.id).toBe(first.body.data.id);
|
|
||||||
expect(first.body.data.total.amount).toBe("116.91");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports role changes without recreating the session", async () => {
|
|
||||||
const authenticated = await login("viewer@complex.demo");
|
|
||||||
await authenticated.agent
|
|
||||||
.post("/api/v1/products")
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.set("X-CSRF-Token", authenticated.csrfToken)
|
|
||||||
.send({})
|
|
||||||
.expect(403);
|
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/testing/users/complex-user-viewer/role")
|
|
||||||
.send({ role: "manager" })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
const allowedToReachValidation = await authenticated.agent
|
|
||||||
.post("/api/v1/products")
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.set("X-CSRF-Token", authenticated.csrfToken)
|
|
||||||
.send({})
|
|
||||||
.expect(400);
|
|
||||||
expect(allowedToReachValidation.body.code).toBe("BAD_REQUEST");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expires an active session on demand", async () => {
|
|
||||||
const authenticated = await login();
|
|
||||||
await authenticated.agent
|
|
||||||
.post("/api/v1/testing/session/expire")
|
|
||||||
.set("X-CSRF-Token", authenticated.csrfToken)
|
|
||||||
.expect(200);
|
|
||||||
await authenticated.agent.get("/api/v1/users/me").expect(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs a 202 background export and exposes the completed result", async () => {
|
|
||||||
const authenticated = await login();
|
|
||||||
const started = await authenticated.agent
|
|
||||||
.post("/api/v1/exports/orders")
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.set("X-CSRF-Token", authenticated.csrfToken)
|
|
||||||
.expect(202);
|
|
||||||
const jobId = started.body.data.id as string;
|
|
||||||
|
|
||||||
await authenticated.agent
|
|
||||||
.get(`/api/v1/jobs/${jobId}/result`)
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.expect(409);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 850));
|
|
||||||
const completed = await authenticated.agent
|
|
||||||
.get(`/api/v1/jobs/${jobId}`)
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.expect(200);
|
|
||||||
expect(completed.body.data).toMatchObject({
|
|
||||||
status: "completed",
|
|
||||||
progress: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await authenticated.agent
|
|
||||||
.get(`/api/v1/jobs/${jobId}/result`)
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.expect(200);
|
|
||||||
expect(result.headers["content-type"]).toMatch(/text\/csv/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uploads and downloads a multipart file", async () => {
|
|
||||||
const authenticated = await login();
|
|
||||||
const uploaded = await authenticated.agent
|
|
||||||
.post("/api/v1/files")
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.set("X-CSRF-Token", authenticated.csrfToken)
|
|
||||||
.attach("file", Buffer.from("sku,stock\nABC,10\n"), "inventory.csv")
|
|
||||||
.expect(201);
|
|
||||||
expect(uploaded.body.data.name).toBe("inventory.csv");
|
|
||||||
|
|
||||||
const downloaded = await authenticated.agent
|
|
||||||
.get(`/api/v1/files/${uploaded.body.data.id as string}/download`)
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.expect(200);
|
|
||||||
expect(downloaded.headers["content-disposition"]).toContain(
|
|
||||||
"inventory.csv",
|
|
||||||
);
|
|
||||||
|
|
||||||
const tooLarge = await authenticated.agent
|
|
||||||
.post("/api/v1/files")
|
|
||||||
.set("X-Organization-Id", "org-acme")
|
|
||||||
.set("X-CSRF-Token", authenticated.csrfToken)
|
|
||||||
.attach("file", Buffer.alloc(5 * 1024 * 1024 + 1), "too-large.bin")
|
|
||||||
.expect(413);
|
|
||||||
expect(tooLarge.body.code).toBe("FILE_TOO_LARGE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("authenticates Socket.IO with the session cookie and deduplicates messages", async () => {
|
|
||||||
const authenticated = await login();
|
|
||||||
const socket = io(`${baseUrl}/chat`, {
|
|
||||||
transports: ["websocket"],
|
|
||||||
reconnection: false,
|
|
||||||
forceNew: true,
|
|
||||||
extraHeaders: { Cookie: authenticated.cookieHeader },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForSocketEvent(socket, "connect");
|
|
||||||
const joined = waitForSocketEvent<{ conversationId: string }>(
|
|
||||||
socket,
|
|
||||||
"chat:joined",
|
|
||||||
);
|
|
||||||
socket.emit("chat:join", {
|
|
||||||
organizationId: "org-acme",
|
|
||||||
conversationId: "conversation-support",
|
|
||||||
});
|
|
||||||
expect((await joined).conversationId).toBe("conversation-support");
|
|
||||||
|
|
||||||
const messagePayload = {
|
|
||||||
organizationId: "org-acme",
|
|
||||||
conversationId: "conversation-support",
|
|
||||||
text: "Socket E2E message",
|
|
||||||
clientMessageId: "socket-e2e-001",
|
|
||||||
};
|
|
||||||
const firstAck = waitForSocketEvent<{ id: string }>(socket, "message:ack");
|
|
||||||
socket.emit("message:send", messagePayload);
|
|
||||||
const first = await firstAck;
|
|
||||||
|
|
||||||
const secondAck = waitForSocketEvent<{ id: string }>(socket, "message:ack");
|
|
||||||
socket.emit("message:send", messagePayload);
|
|
||||||
const second = await secondAck;
|
|
||||||
expect(second.id).toBe(first.id);
|
|
||||||
socket.disconnect();
|
|
||||||
}, 10_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
function waitForSocketEvent<T = void>(
|
|
||||||
socket: Socket,
|
|
||||||
event: string,
|
|
||||||
timeoutMs = 3000,
|
|
||||||
): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
socket.off(event, handler);
|
|
||||||
reject(new Error(`Timed out waiting for Socket.IO event ${event}.`));
|
|
||||||
}, timeoutMs);
|
|
||||||
const handler = (payload: T) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(payload);
|
|
||||||
};
|
|
||||||
socket.once(event, handler);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
import type { INestApplication } from "@nestjs/common";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createSimpleApplication } from "../src/apps/simple/bootstrap";
|
|
||||||
|
|
||||||
describe("Simple API", () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const created = await createSimpleApplication(false);
|
|
||||||
app = created.app;
|
|
||||||
await app.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/testing/reset")
|
|
||||||
.expect(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function login(email = "admin@demo.local") {
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.post("/api/v1/auth/login")
|
|
||||||
.send({ email, password: "demo1234" })
|
|
||||||
.expect(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("serves health, Swagger JSON and observable headers", async () => {
|
|
||||||
const health = await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/health")
|
|
||||||
.set("X-Request-Id", "frontend-request-001")
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(health.body.data).toMatchObject({
|
|
||||||
application: "simple",
|
|
||||||
status: "ok",
|
|
||||||
});
|
|
||||||
expect(health.headers["x-request-id"]).toBe("frontend-request-001");
|
|
||||||
expect(health.headers["x-response-time"]).toMatch(/^\d+ms$/);
|
|
||||||
|
|
||||||
const contract = await request(app.getHttpServer())
|
|
||||||
.get("/openapi.json")
|
|
||||||
.expect(200);
|
|
||||||
expect(contract.body.info.title).toBe("Demo Simple API");
|
|
||||||
expect(contract.body.components.securitySchemes.jwt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports empty, large, error and rate-limit scenarios deterministically", async () => {
|
|
||||||
const empty = await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/products")
|
|
||||||
.set("X-Demo-Scenario", "empty")
|
|
||||||
.expect(200);
|
|
||||||
expect(empty.body.data).toEqual([]);
|
|
||||||
expect(empty.body.meta.total).toBe(0);
|
|
||||||
|
|
||||||
const large = await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/categories")
|
|
||||||
.set("X-Demo-Scenario", "large-dataset")
|
|
||||||
.expect(200);
|
|
||||||
expect(large.body.data).toHaveLength(250);
|
|
||||||
|
|
||||||
const limited = await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/products")
|
|
||||||
.set("X-Demo-Scenario", "rate-limited")
|
|
||||||
.expect(429);
|
|
||||||
expect(limited.headers["retry-after"]).toBe("3");
|
|
||||||
expect(limited.body.code).toBe("DEMO_RATE_LIMITED");
|
|
||||||
|
|
||||||
const failed = await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/products")
|
|
||||||
.set("X-Demo-Scenario", "server-error")
|
|
||||||
.expect(500);
|
|
||||||
expect(failed.body.code).toBe("DEMO_SERVER_ERROR");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("authenticates, rotates refresh tokens and rejects token reuse", async () => {
|
|
||||||
const authenticated = await login();
|
|
||||||
const { accessToken, refreshToken } = authenticated.body.data.tokens;
|
|
||||||
|
|
||||||
const me = await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/users/me")
|
|
||||||
.set("Authorization", `Bearer ${accessToken}`)
|
|
||||||
.expect(200);
|
|
||||||
expect(me.body.data.email).toBe("admin@demo.local");
|
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/users/me")
|
|
||||||
.set("Authorization", `Bearer ${accessToken}`)
|
|
||||||
.set("X-Demo-Scenario", "expired-auth")
|
|
||||||
.expect(401);
|
|
||||||
|
|
||||||
const rotated = await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/auth/refresh")
|
|
||||||
.send({ refreshToken })
|
|
||||||
.expect(200);
|
|
||||||
expect(rotated.body.data.tokens.refreshToken).not.toBe(refreshToken);
|
|
||||||
|
|
||||||
const reused = await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/auth/refresh")
|
|
||||||
.send({ refreshToken })
|
|
||||||
.expect(401);
|
|
||||||
expect(reused.body.code).toBe("REFRESH_TOKEN_REUSED");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 304 for a current product ETag", async () => {
|
|
||||||
const first = await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/products/product-keyboard")
|
|
||||||
.expect(200);
|
|
||||||
expect(first.headers.etag).toBeDefined();
|
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/products/product-keyboard")
|
|
||||||
.set("If-None-Match", first.headers.etag)
|
|
||||||
.expect(304);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects stale product updates and returns structured conflicts", async () => {
|
|
||||||
const authenticated = await login();
|
|
||||||
const accessToken = authenticated.body.data.tokens.accessToken as string;
|
|
||||||
const authorization = `Bearer ${accessToken}`;
|
|
||||||
|
|
||||||
const updated = await request(app.getHttpServer())
|
|
||||||
.patch("/api/v1/products/product-keyboard")
|
|
||||||
.set("Authorization", authorization)
|
|
||||||
.send({ version: 1, name: "Mechanical Keyboard Updated" })
|
|
||||||
.expect(200);
|
|
||||||
expect(updated.body.data.version).toBe(2);
|
|
||||||
|
|
||||||
const conflict = await request(app.getHttpServer())
|
|
||||||
.patch("/api/v1/products/product-keyboard")
|
|
||||||
.set("Authorization", authorization)
|
|
||||||
.send({ version: 1, name: "Stale Update" })
|
|
||||||
.expect(409);
|
|
||||||
expect(conflict.body.code).toBe("PRODUCT_VERSION_CONFLICT");
|
|
||||||
expect(conflict.body.requestId).toMatch(/^req-/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enforces roles and validates nested orders", async () => {
|
|
||||||
const customer = await login("customer@demo.local");
|
|
||||||
const authorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
|
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/products")
|
|
||||||
.set("Authorization", authorization)
|
|
||||||
.send({})
|
|
||||||
.expect(403);
|
|
||||||
|
|
||||||
const order = await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/orders")
|
|
||||||
.set("Authorization", authorization)
|
|
||||||
.send({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
productId: "product-keyboard",
|
|
||||||
quantity: 1,
|
|
||||||
expectedVersion: 1,
|
|
||||||
expectedUnitPriceCents: 12990,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.expect(201);
|
|
||||||
expect(order.body.data.userId).toBe("user-customer");
|
|
||||||
expect(order.body.data.totalCents).toBe(12990);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not execute a timeout mutation after the client disconnects", async () => {
|
|
||||||
const customer = await login("customer@demo.local");
|
|
||||||
const authorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
|
|
||||||
const ordersBefore = await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/orders")
|
|
||||||
.set("Authorization", authorization)
|
|
||||||
.expect(200);
|
|
||||||
const previousDelay = process.env.MOCK_TIMEOUT_DELAY_MS;
|
|
||||||
|
|
||||||
process.env.MOCK_TIMEOUT_DELAY_MS = "100";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/orders")
|
|
||||||
.set("Authorization", authorization)
|
|
||||||
.set("X-Demo-Scenario", "timeout")
|
|
||||||
.send({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
productId: "product-keyboard",
|
|
||||||
quantity: 1,
|
|
||||||
expectedVersion: 1,
|
|
||||||
expectedUnitPriceCents: 12990,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.timeout({ deadline: 10 });
|
|
||||||
} catch {
|
|
||||||
// The client intentionally closes the response before the mutation delay elapses.
|
|
||||||
} finally {
|
|
||||||
if (previousDelay === undefined) {
|
|
||||||
delete process.env.MOCK_TIMEOUT_DELAY_MS;
|
|
||||||
} else {
|
|
||||||
process.env.MOCK_TIMEOUT_DELAY_MS = previousDelay;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
const ordersAfter = await request(app.getHttpServer())
|
|
||||||
.get("/api/v1/orders")
|
|
||||||
.set("Authorization", authorization)
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(ordersAfter.body.meta.total).toBe(ordersBefore.body.meta.total);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an order when the confirmed product snapshot changed", async () => {
|
|
||||||
const customer = await login("customer@demo.local");
|
|
||||||
const authorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
|
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/orders")
|
|
||||||
.set("Authorization", authorization)
|
|
||||||
.send({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
productId: "product-keyboard",
|
|
||||||
quantity: 1,
|
|
||||||
expectedVersion: 1,
|
|
||||||
expectedUnitPriceCents: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.expect(409);
|
|
||||||
|
|
||||||
expect(response.body.code).toBe("PRODUCT_CHANGED");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects duplicate product lines and unsupported order currency", async () => {
|
|
||||||
const admin = await login();
|
|
||||||
const adminAuthorization = `Bearer ${admin.body.data.tokens.accessToken as string}`;
|
|
||||||
const customer = await login("customer@demo.local");
|
|
||||||
const customerAuthorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
|
|
||||||
const line = {
|
|
||||||
productId: "product-keyboard",
|
|
||||||
quantity: 20,
|
|
||||||
expectedVersion: 1,
|
|
||||||
expectedUnitPriceCents: 12990,
|
|
||||||
};
|
|
||||||
const duplicateResponse = await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/orders")
|
|
||||||
.set("Authorization", customerAuthorization)
|
|
||||||
.send({ items: [line, line] })
|
|
||||||
.expect(422);
|
|
||||||
|
|
||||||
expect(duplicateResponse.body.code).toBe("DUPLICATE_ORDER_PRODUCT");
|
|
||||||
|
|
||||||
const eurProduct = await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/products")
|
|
||||||
.set("Authorization", adminAuthorization)
|
|
||||||
.send({
|
|
||||||
name: "Euro Product",
|
|
||||||
description: "A deterministic product priced in euros.",
|
|
||||||
priceCents: 1000,
|
|
||||||
currency: "EUR",
|
|
||||||
categoryId: "category-books",
|
|
||||||
stock: 5,
|
|
||||||
imageUrl: "https://picsum.photos/seed/euro-product/640/480",
|
|
||||||
})
|
|
||||||
.expect(201);
|
|
||||||
const currencyResponse = await request(app.getHttpServer())
|
|
||||||
.post("/api/v1/orders")
|
|
||||||
.set("Authorization", customerAuthorization)
|
|
||||||
.send({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
productId: eurProduct.body.data.id,
|
|
||||||
quantity: 1,
|
|
||||||
expectedVersion: eurProduct.body.data.version,
|
|
||||||
expectedUnitPriceCents: eurProduct.body.data.priceCents,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.expect(422);
|
|
||||||
|
|
||||||
expect(currencyResponse.body.code).toBe("UNSUPPORTED_ORDER_CURRENCY");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"incremental": false,
|
|
||||||
"rootDir": "./src"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "commonjs",
|
|
||||||
"declaration": true,
|
|
||||||
"removeComments": false,
|
|
||||||
"emitDecoratorMetadata": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"target": "ES2022",
|
|
||||||
"sourceMap": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"baseUrl": "./",
|
|
||||||
"incremental": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"strictPropertyInitialization": false,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
"useUnknownInCatchVariables": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "test/**/*.ts"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
VITE_SIMPLE_API_URL=http://localhost:3001
|
|
||||||
27
examples/react-vite/.gitignore
vendored
27
examples/react-vite/.gitignore
vendored
@@ -1,27 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
||||||
"plugins": ["react", "typescript", "oxc"],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"patterns": [
|
|
||||||
"domains/*/*",
|
|
||||||
"infra/*/*",
|
|
||||||
"ui/*/*",
|
|
||||||
"shared/lib/*/*",
|
|
||||||
"compositions/screens/*/*",
|
|
||||||
"compositions/layouts/*/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"react/rules-of-hooks": "error",
|
|
||||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# SLM Store
|
|
||||||
|
|
||||||
Облегчённое React + Vite приложение по Scoped Layered Module Design. Оно использует только маленький контракт [`../demo-backend/openapi/simple.json`](../demo-backend/openapi/simple.json); `complex.json` намеренно не включён в runtime-граф.
|
|
||||||
|
|
||||||
## Возможности
|
|
||||||
|
|
||||||
- JWT login, однократный конкурентный refresh и idempotent logout.
|
|
||||||
- Вход под admin и customer demo-учётными записями.
|
|
||||||
- Каталог с поиском, категориями, сортировкой и offset pagination.
|
|
||||||
- Admin create, update с optimistic locking и delete продукта.
|
|
||||||
- Draft order с фиксацией версии и цены продукта.
|
|
||||||
- Checkout, stock/currency validation, история и отмена заказов.
|
|
||||||
- Собственные доменные модели, исходы и runtime-проверка внешних ответов.
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
Требуются Node.js 20+ и npm 10+.
|
|
||||||
|
|
||||||
Сначала запустите Simple backend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd examples/demo-backend
|
|
||||||
npm install
|
|
||||||
npm run dev:simple
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем в отдельном терминале запустите frontend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd examples/react-vite
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend откроется на `http://localhost:5173`, backend работает на `http://localhost:3001`.
|
|
||||||
|
|
||||||
## Demo-пользователи
|
|
||||||
|
|
||||||
| Роль | Email | Пароль |
|
|
||||||
|---|---|---|
|
|
||||||
| Administrator | `admin@demo.local` | `demo1234` |
|
|
||||||
| Customer | `customer@demo.local` | `demo1234` |
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
| Переменная | Назначение | Значение по умолчанию |
|
|
||||||
|---|---|---|
|
|
||||||
| `VITE_SIMPLE_API_URL` | Base URL Simple API | `http://localhost:3001` |
|
|
||||||
|
|
||||||
Access token живёт в памяти вкладки. Refresh token хранится в `sessionStorage` и очищается вместе с session-scoped SWR cache при logout или окончательном истечении сессии.
|
|
||||||
|
|
||||||
## OpenAPI
|
|
||||||
|
|
||||||
Generated-код находится в `src/infra/simple-rest-api/generated` и не редактируется вручную. Регенерация использует зафиксированную версию `@gromlab/api-codegen`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run codegen:simple-rest-api
|
|
||||||
```
|
|
||||||
|
|
||||||
OpenAPI ошибочно описывает `page` и `limit` через пустую `Object` schema. Исправленный browser-контракт локализован внутри `infra/simple-rest-api/types`; generated-файлы остаются неизменными.
|
|
||||||
|
|
||||||
## SLM
|
|
||||||
|
|
||||||
SLM root: `src`.
|
|
||||||
|
|
||||||
| Слой | Владельцы и ответственность |
|
|
||||||
|---|---|
|
|
||||||
| `app` | Vite entry, router, application providers и cache lifecycle |
|
|
||||||
| `compositions` | `sign-in`, `storefront`, `app-shell`; только размещение и связывание публичных API |
|
|
||||||
| `domains` | `session`, `catalog`, `orders`; модели, сценарии, исходы, состояние, UI и source adaptation |
|
|
||||||
| `infra` | `simple-rest-api`; generated SDK, transport credentials, refresh race и SWR GET-хуки |
|
|
||||||
| `ui` | Универсальные `button` и `field` |
|
|
||||||
| `shared` | Чистые formatters и value predicates |
|
|
||||||
|
|
||||||
Свёрнутый граф модулей:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app -> compositions
|
|
||||||
app -> domains
|
|
||||||
compositions -> compositions
|
|
||||||
compositions -> domains
|
|
||||||
domains -> infra
|
|
||||||
domains -> ui
|
|
||||||
domains -> shared
|
|
||||||
```
|
|
||||||
|
|
||||||
Каждый внешний импорт проходит через корневой `index.ts` целевого модуля. В корне модуля находятся только публичные фасеты и не более одного главного implementation/assembly-файла; context, hooks, source, types и прочая реализация находятся в сегментах.
|
|
||||||
|
|
||||||
### Владение состоянием
|
|
||||||
|
|
||||||
| Состояние | Владелец | Область жизни |
|
|
||||||
|---|---|---|
|
|
||||||
| Пользователь и session status | `domains/session` | Всё browser-приложение |
|
|
||||||
| JWT credentials и refresh promise | `infra/simple-rest-api` | Вкладка / transport singleton |
|
|
||||||
| Product/order server state | REST hooks + доменная адаптация | Application SWR cache |
|
|
||||||
| Draft order | `domains/orders` | Авторизованный storefront route |
|
|
||||||
| Фильтры и editor state | `domains/catalog` | Экземпляр CatalogPanel |
|
|
||||||
|
|
||||||
## Проверки
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run lint
|
|
||||||
npm run typecheck
|
|
||||||
npm run test
|
|
||||||
npm run build
|
|
||||||
npm run check
|
|
||||||
```
|
|
||||||
|
|
||||||
Тесты покрывают полный login-to-checkout smoke, объединение конкурентных 401 в один refresh и независимое преобразование source errors в доменные исходы.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/slm-store.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="SLM Store: демонстрационное React-приложение для Simple API."
|
|
||||||
/>
|
|
||||||
<title>SLM Store</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
2657
examples/react-vite/package-lock.json
generated
2657
examples/react-vite/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "react-vite",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"description": "Lightweight SLM React storefront for the Demo Simple API",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"codegen:simple-rest-api": "npx @gromlab/api-codegen@5.1.0 -i ../demo-backend/openapi/simple.json -o src/infra/simple-rest-api/generated",
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "oxlint",
|
|
||||||
"typecheck": "tsc -b",
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"check": "npm run lint && npm run typecheck && npm run test && npm run build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"react": "^19.2.8",
|
|
||||||
"react-dom": "^19.2.8",
|
|
||||||
"react-router-dom": "^7.18.2",
|
|
||||||
"swr": "^2.5.0",
|
|
||||||
"zod": "^4.4.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@testing-library/jest-dom": "^7.0.1",
|
|
||||||
"@testing-library/react": "^16.3.2",
|
|
||||||
"@testing-library/user-event": "^14.6.3",
|
|
||||||
"@types/node": "^24.13.3",
|
|
||||||
"@types/react": "^19.2.17",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^6.0.4",
|
|
||||||
"jsdom": "^30.0.1",
|
|
||||||
"oxlint": "^1.75.0",
|
|
||||||
"typescript": "~6.0.2",
|
|
||||||
"vite": "^8.2.0",
|
|
||||||
"vitest": "^4.1.10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
||||||
<rect width="64" height="64" rx="18" fill="#192c23"/>
|
|
||||||
<path fill="#e36f4f" d="M16 17h35L39 31h10L25 51V36H13l12-19h-9Z"/>
|
|
||||||
<circle cx="46" cy="17" r="5" fill="#9ee0b9"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 243 B |
@@ -1,151 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import { clearSimpleRestApiTokens } from 'infra/simple-rest-api'
|
|
||||||
import { App } from './app'
|
|
||||||
import { AppProviders } from './providers/app-providers'
|
|
||||||
|
|
||||||
const demoUser = {
|
|
||||||
id: 'user-admin',
|
|
||||||
email: 'admin@demo.local',
|
|
||||||
name: 'Demo Admin',
|
|
||||||
role: 'admin',
|
|
||||||
avatarUrl: 'https://i.pravatar.cc/160?img=12'
|
|
||||||
}
|
|
||||||
|
|
||||||
const demoProduct = {
|
|
||||||
id: 'product-keyboard',
|
|
||||||
name: 'Mechanical Keyboard',
|
|
||||||
slug: 'mechanical-keyboard',
|
|
||||||
description: 'Mechanical Keyboard is a deterministic demo product used by frontend examples.',
|
|
||||||
priceCents: 12990,
|
|
||||||
currency: 'USD',
|
|
||||||
categoryId: 'category-electronics',
|
|
||||||
stock: 24,
|
|
||||||
rating: 4.8,
|
|
||||||
imageUrl: 'https://picsum.photos/seed/mechanical-keyboard/640/480',
|
|
||||||
createdAt: '2026-07-10T09:00:00.000Z',
|
|
||||||
version: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создаёт JSON response, совместимый с generated HttpClient.
|
|
||||||
*/
|
|
||||||
const jsonResponse = (body: unknown, status = 200): Response => {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Эмулирует минимальный Simple API для app-level smoke-сценария.
|
|
||||||
*/
|
|
||||||
const createSimpleApiFetch = () => {
|
|
||||||
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
||||||
const url = new URL(String(input))
|
|
||||||
const method = init?.method ?? 'GET'
|
|
||||||
|
|
||||||
if (url.pathname === '/api/v1/auth/login' && method === 'POST') {
|
|
||||||
return jsonResponse({
|
|
||||||
data: {
|
|
||||||
tokens: {
|
|
||||||
accessToken: 'access-token',
|
|
||||||
refreshToken: 'refresh-token',
|
|
||||||
expiresIn: 60,
|
|
||||||
tokenType: 'Bearer'
|
|
||||||
},
|
|
||||||
user: demoUser
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/api/v1/products' && method === 'GET') {
|
|
||||||
return jsonResponse({
|
|
||||||
data: [demoProduct],
|
|
||||||
meta: { page: 1, limit: 6, total: 1, totalPages: 1 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/api/v1/categories' && method === 'GET') {
|
|
||||||
return jsonResponse({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: 'category-electronics',
|
|
||||||
name: 'Electronics',
|
|
||||||
slug: 'electronics',
|
|
||||||
productCount: 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/api/v1/orders' && method === 'GET') {
|
|
||||||
return jsonResponse({
|
|
||||||
data: [],
|
|
||||||
meta: { page: 1, limit: 20, total: 0, totalPages: 0 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/api/v1/orders' && method === 'POST') {
|
|
||||||
return jsonResponse(
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
id: 'order-010',
|
|
||||||
userId: 'user-admin',
|
|
||||||
status: 'pending',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
productId: demoProduct.id,
|
|
||||||
productName: demoProduct.name,
|
|
||||||
quantity: 1,
|
|
||||||
unitPriceCents: demoProduct.priceCents
|
|
||||||
}
|
|
||||||
],
|
|
||||||
totalCents: demoProduct.priceCents,
|
|
||||||
currency: 'USD',
|
|
||||||
createdAt: '2026-08-10T10:00:00.000Z'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
201
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse({ code: 'NOT_FOUND', message: 'Not found' }, 404)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
clearSimpleRestApiTokens()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('authenticates, loads domain data and checks out a product', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
const fetchMock = createSimpleApiFetch()
|
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
|
||||||
|
|
||||||
render(
|
|
||||||
<AppProviders>
|
|
||||||
<App />
|
|
||||||
</AppProviders>
|
|
||||||
)
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Войти в магазин' }))
|
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { name: 'Mechanical Keyboard' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Demo Admin')).toBeInTheDocument()
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'В заказ' }))
|
|
||||||
expect(screen.getByText('Draft order')).toBeInTheDocument()
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Оформить заказ' }))
|
|
||||||
|
|
||||||
expect(await screen.findByText('order-010')).toBeInTheDocument()
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'http://localhost:3001/api/v1/orders',
|
|
||||||
expect.objectContaining({ method: 'POST' })
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
|
||||||
|
|
||||||
import { SignInScreen } from 'compositions/screens/sign-in'
|
|
||||||
import { StorefrontScreen } from 'compositions/screens/storefront'
|
|
||||||
import { OrdersProvider } from 'domains/orders'
|
|
||||||
import { useSessionState } from 'domains/session'
|
|
||||||
import styles from './styles/app.module.css'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Browser entry, выбирающий экран по session lifecycle.
|
|
||||||
*
|
|
||||||
* Используется для:
|
|
||||||
* - защиты storefront route пользовательской сессией
|
|
||||||
* - завершения bootstrap до первого route render
|
|
||||||
*/
|
|
||||||
export const App = () => {
|
|
||||||
const { status } = useSessionState()
|
|
||||||
|
|
||||||
if (status === 'restoring') {
|
|
||||||
return (
|
|
||||||
<main className={styles.loader} aria-live="polite">
|
|
||||||
<span className={styles.loaderMark}>S</span>
|
|
||||||
<strong>Восстанавливаем сессию</strong>
|
|
||||||
<small>Simple API · JWT rotation</small>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAuthenticated = status === 'authenticated'
|
|
||||||
const signInElement = isAuthenticated ? <Navigate to="/" replace /> : <SignInScreen />
|
|
||||||
const storefrontElement = isAuthenticated ? (
|
|
||||||
<OrdersProvider>
|
|
||||||
<StorefrontScreen />
|
|
||||||
</OrdersProvider>
|
|
||||||
) : (
|
|
||||||
<Navigate to="/login" replace />
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={signInElement} />
|
|
||||||
<Route path="/" element={storefrontElement} />
|
|
||||||
<Route path="*" element={<Navigate to={isAuthenticated ? '/' : '/login'} replace />} />
|
|
||||||
</Routes>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
|
||||||
import { SWRConfig } from 'swr'
|
|
||||||
|
|
||||||
import { SessionProvider } from 'domains/session'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props глобальной provider-композиции browser-приложения.
|
|
||||||
*/
|
|
||||||
export type AppProvidersProps = {
|
|
||||||
/** Browser-приложение внутри общего cache и session scope. */
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Собирает технические providers application scope.
|
|
||||||
*
|
|
||||||
* Используется для:
|
|
||||||
* - одного SWR cache на browser-приложение
|
|
||||||
* - одного session lifecycle и history router
|
|
||||||
*/
|
|
||||||
export const AppProviders = (props: AppProvidersProps) => {
|
|
||||||
const { children } = props
|
|
||||||
const [swrCache] = useState(() => new Map())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Удаляет server state предыдущего пользователя при закрытии сессии.
|
|
||||||
*/
|
|
||||||
const handleSessionClosed = (): void => {
|
|
||||||
swrCache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SWRConfig
|
|
||||||
value={{
|
|
||||||
provider: () => swrCache,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
shouldRetryOnError: false
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SessionProvider onSessionClosed={handleSessionClosed}>
|
|
||||||
<BrowserRouter>{children}</BrowserRouter>
|
|
||||||
</SessionProvider>
|
|
||||||
</SWRConfig>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
.loader {
|
|
||||||
display: grid;
|
|
||||||
min-height: 100svh;
|
|
||||||
place-items: center;
|
|
||||||
align-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: var(--color-ink-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loaderMark {
|
|
||||||
display: grid;
|
|
||||||
width: 3.4rem;
|
|
||||||
height: 3.4rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 1rem 1rem 1rem 0.25rem;
|
|
||||||
color: #fffaf0;
|
|
||||||
background: var(--color-accent-strong);
|
|
||||||
box-shadow: 0 14px 35px rgb(31 86 62 / 22%);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.7rem;
|
|
||||||
animation: breathe 1.4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader strong {
|
|
||||||
color: var(--color-ink);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader small {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.65rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes breathe {
|
|
||||||
50% {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
:root {
|
|
||||||
--color-canvas: #f5f0e5;
|
|
||||||
--color-surface: #fffdf7;
|
|
||||||
--color-surface-strong: #ebe6da;
|
|
||||||
--color-ink: #192c23;
|
|
||||||
--color-ink-muted: #647069;
|
|
||||||
--color-ink-faint: #929a95;
|
|
||||||
--color-line: rgb(27 48 38 / 13%);
|
|
||||||
--color-line-strong: rgb(27 48 38 / 25%);
|
|
||||||
--color-accent: #e36f4f;
|
|
||||||
--color-accent-strong: #bd4e35;
|
|
||||||
--color-accent-light: #9ee0b9;
|
|
||||||
--color-accent-soft: #f7e4d9;
|
|
||||||
--color-danger: #aa3d32;
|
|
||||||
--color-danger-soft: #fae2de;
|
|
||||||
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
--font-display: Georgia, 'Times New Roman', serif;
|
|
||||||
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
|
||||||
--shadow-card: 0 12px 35px rgb(30 45 38 / 7%);
|
|
||||||
--shadow-card-hover: 0 20px 48px rgb(30 45 38 / 12%);
|
|
||||||
|
|
||||||
color: var(--color-ink);
|
|
||||||
background: var(--color-canvas);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100svh;
|
|
||||||
margin: 0;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 10% 10%, rgb(227 111 79 / 9%), transparent 24rem),
|
|
||||||
radial-gradient(circle at 90% 35%, rgb(78 148 107 / 8%), transparent 28rem),
|
|
||||||
var(--color-canvas);
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
a {
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
color: #fffaf0;
|
|
||||||
background: var(--color-accent-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user