Files
pchelpmaster/docs/architecture.md
T
kamaskandClaude Sonnet 5.5 bf72af9ef4 feat(dev): proxy /pb to PocketBase in ng serve
Add proxy.conf.mjs mirroring `location /pb/` from nginx: the prefix is
stripped, target comes from PB_URL (empty value falls back to the default,
same as the SSR provider). Cover the config with tests in tests/lint and
update docs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:24:13 +03:00

225 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура
Архитектурные решения проекта. Если код или новая задача им противоречит, сначала обсуждается и правится этот документ.
## 1. Общая схема
```
┌─ location /pb/ ─(префикс /pb срезается)─► PocketBase :8090
Браузер ──► nginx ──┤ ▲
└─ location / ──► Node SSR-сервер :4000 ────────┘
(src/server.ts) PB_URL — напрямую, без nginx
```
- Браузер ходит в PocketBase по пути `/pb/*` своего origin: SDK создаётся с адресом `<origin>/pb`, запрос `/pb/api/collections/...` nginx отправляет в PocketBase как `/api/collections/...`.
- Node SSR-сервер при рендере обращается к PocketBase напрямую по внутреннему адресу `PB_URL`.
- В разработке nginx нет: его роль для `/pb/*` выполняет прокси dev-сервера Angular (`proxy.conf.mjs`).
- **PocketBase** — единственный бэкенд: база, авторизация, файлы, правила доступа (API rules), кастомные маршруты (`pocketbase/pb_hooks/`). Это же и публичный API для будущих клиентов (мобильное приложение, Telegram-бот, десктоп).
- **Безопасность данных обеспечивает PocketBase**, а не фронтенд. Фронтенд может быть скомпрометирован или заменён другим клиентом, поэтому изоляция пользователей проверяется API-тестами на уровне правил коллекций.
- **SSR-сервер** только рендерит страницы и отдаёт статику, API PocketBase он не проксирует. Браузер всегда обращается к своему origin (nginx): нет CORS, cookie общая для приложения.
## 2. Слои и структура папок
Код организован по фичам (`src/app/<feature>/`). Внутри фичи — слои в духе DDD:
```
src/app/orders/
index.ts ← публичный API фичи: что можно импортировать другим фичам
orders.routes.ts ← маршруты фичи (lazy)
provide-orders.ts ← provideOrders(): регистрация реализаций в DI
domain/ ← чистый TypeScript, без Angular и без PocketBase
order.ts ← модель (типы)
order-status.ts ← статусы и подписи
order-filter.ts ← параметры фильтрации/поиска
order-repository.ts ← abstract class OrderRepository (контракт и DI-токен)
data/ ← реализация контрактов через PocketBase
pocketbase-order-repository.ts
order-mapper.ts ← запись PocketBase ⇄ доменная модель
ui/ ← компоненты (одна папка на компонент, если есть html/css)
order-list/order-list.ts
order-form/order-form.ts
testing/ ← фейки для тестов других слоёв и фич
in-memory-order-repository.ts
```
Правила:
1. **domain** не импортирует ничего из `@angular/*`, `pocketbase`, `data/`, `ui/`. Здесь только типы, чистые функции и абстрактные классы.
2. **data** знает про PocketBase и домен. Наружу отдаёт только доменные типы: запись PocketBase (`RecordModel`) не покидает слой `data`.
3. **ui** знает только домен и абстрактные классы. Компоненты никогда не импортируют `pocketbase` и `data/`.
4. Фича импортирует другую фичу **только через её `index.ts`**. Глубокие импорты (`../clients/data/...`) запрещены.
5. Общая инфраструктура, нужная всем фичам, живёт в `src/app/shared/` (например `shared/pocketbase/`, позже `shared/ui/`). Туда кладём только то, что реально используется двумя и более фичами.
### Фичи MVP
| Папка | Назначение |
| -------------------- | ---------------------------------------------------------------------------------------- |
| `shared/pocketbase/` | URL PocketBase, фабрика клиента, синхронизация cookie |
| `shared/errors/` | доменные ошибки и `toDomainError` |
| `users/` | сущность User (профиль владельца аккаунта), без логики входа |
| `auth/` | сессия: вход, регистрация, выход, текущий пользователь, guard'ы, формы входа/регистрации |
| `layout/` | каркас приложения: шапка, навигация, меню пользователя |
| `home/` | главная страница: лендинг для гостя или список заявок для вошедшего |
| `tags/` | метки пользователя: диалог управления метками и компонент выбора меток |
| `clients/` | клиенты |
| `orders/` | заявки, вложения |
| `share/` | публичная ссылка и публичная страница заявки |
| `not-found/` | страница 404 |
### Направление зависимостей между фичами
```
home ──► orders ──► clients ──► shared
│ │ └───► tags ─────► shared
│ └──────► share ────► shared
└──► auth ──► users
```
- Зависимости идут только в одну сторону, циклов нет.
- Если «нижней» фиче нужно показать что-то из «верхней» (например, история заявок в карточке клиента), нижняя объявляет точку расширения — `InjectionToken` со списком компонентов, — а верхняя регистрирует в ней свой компонент в `provideXxx()`. Так `clients` ничего не знает о заявках.
> Сущность заявки в коде называется **`Order`**; коллекция, папка и маршруты — `orders`.
## 3. Dependency Injection через абстрактные классы
Каждый репозиторий и сервис, у которого может появиться другая реализация, описан **абстрактным классом**. Абстрактный класс одновременно контракт и DI-токен.
```ts
// tags/domain/tag-repository.ts
export abstract class TagRepository {
abstract list(): Promise<Tag[]>;
abstract create(draft: TagDraft): Promise<Tag>;
abstract update(id: string, changes: Partial<TagDraft>): Promise<Tag>;
abstract delete(id: string): Promise<void>;
}
// tags/provide-tags.ts
export function provideTags(): Provider[] {
return [{ provide: TagRepository, useClass: PocketBaseTagRepository }];
}
// app.config.ts — единственное место, где выбираются реализации
providers: [
providePocketBase(),
provideAuth(),
provideTags(),
provideClients(),
provideOrders(),
provideShare(),
];
```
- Компоненты и сервисы получают зависимость так: `private readonly tags = inject(TagRepository);`.
- Чтобы поменять реализацию, достаточно изменить `provideXxx()` или `app.config.ts`.
- В unit-тестах вместо PocketBase подставляется фейк из `testing/`: `{ provide: TagRepository, useClass: InMemoryTagRepository }`.
- Методы репозиториев возвращают `Promise` (PocketBase SDK промис-ориентирован). В компонентах данные загружаются через `resource()`: так SSR дожидается загрузки (resource регистрирует pending task), а состояние доступно как сигналы.
- Отдельного слоя сторов нет. Стор (сервис с состоянием на сигналах) заводится только там, где одно состояние реально разделяют несколько компонентов, например список меток для диалога управления, компонента выбора и фильтров.
## 4. Ошибки
Доменные ошибки и `toDomainError(error: unknown): Error` живут в `shared/errors/`. `toDomainError` переводит `ClientResponseError` в доменные ошибки, которые понимает UI:
| Доменная ошибка | Когда |
| ------------------------- | ------------------------------------------------------------------------------------- |
| `NotFoundError` | 404 (в том числе «чужая запись»: PocketBase не различает «нет» и «нет доступа») |
| `ValidationError` | 400 с ошибками по полям: `fieldErrors: Record<string, string>` (коды PocketBase) |
| `UnauthorizedError` | 401/403: сессия недействительна |
| `NetworkError` | нет связи / PocketBase недоступен (`status === 0`) или запрос прерван |
| `InvalidCredentialsError` | неверный email или пароль; создаёт сервис аутентификации, `toDomainError` её не знает |
| `UnknownError` | всё остальное, в том числе 400 без ошибок по полям; исходная ошибка — в `cause` |
Все доменные ошибки наследуют `DomainError`: ошибка, уже являющаяся `DomainError`, возвращается из `toDomainError` как есть. Исходная ошибка PocketBase сохраняется в `cause` у каждой ошибки, которую создаёт `toDomainError`.
UI показывает ошибки полей рядом с полями, а общие ошибки — в области уведомлений с `role="alert"`.
## 5. Аутентификация
- Вход и регистрация по email и паролю через коллекцию `users` (PocketBase auth). Регистрация открытая. Подтверждение email и сброс пароля в MVP не делаются.
- Токен PocketBase (JWT) хранится в **cookie `pb_auth`** (`Path=/`, `SameSite=Lax`, `Secure` на HTTPS). Cookie нужна, чтобы SSR-сервер знал, кто пришёл.
- Cookie **не `HttpOnly`**: браузерному SDK нужен токен для прямых запросов в `/pb/api`. Риск — кража токена при XSS. Снижаем его так: Angular экранирует шаблоны, нигде не используется `innerHTML` с пользовательскими данными, позже добавим CSP. Это осознанный компромисс MVP.
- В браузере: при изменении `authStore` cookie перезаписывается (`exportToCookie`); при старте приложения, если токен есть, вызывается `authRefresh()`; при ошибке — выход.
- На сервере: токен читается из заголовка `Cookie` текущего запроса (`loadFromCookie`). Сервер cookie не пишет и токен не обновляет.
## 6. SSR и изоляция данных пользователей
### Какие маршруты как рендерятся
| Маршрут | Режим | Почему |
| --------------------------------------- | ------ | ------------------------------------------------- |
| `/` | Server | лендинг для гостя или список заявок для вошедшего |
| `/orders/:id` | Server | карточка заявки |
| `/p/:token` | Server | публичная страница заявки (превью в мессенджерах) |
| остальные (`/clients/**`, формы заявок) | Client | интерактивные формы, SSR не даёт выгоды |
Настраивается в `src/app/app.routes.server.ts`.
### Правила, без которых данные пользователей могут перемешаться
На сервере каждый HTTP-запрос получает **свой экземпляр Angular-приложения и свой root-инжектор**. Всё, что живёт в DI, изолировано. Опасно только то, что живёт **вне DI** — на уровне модуля, общее для всех запросов.
1. **Нельзя** создавать PocketBase на уровне модуля (`export const pb = new PocketBase(...)`). Экземпляр создаёт только фабрика в `shared/pocketbase/` внутри DI. ESLint запрещает `new PocketBase(` и импорт класса `PocketBase` (в том числе под другим именем и через `pocketbase/*`) во всех остальных файлах; `import type` разрешён.
2. **Нельзя** хранить пользовательские данные в переменных модуля, статических полях классов, кэшах вне DI.
3. Сервер рендерит страницы с заголовками `Cache-Control: private, no-store` и `Vary: Cookie`: ни браузер, ни прокси не должны отдать одну страницу двум людям.
4. На сервере используется глобальный `fetch` без общего состояния; `authStore` — свой у каждого экземпляра PocketBase (не `LocalAuthStore`, а in-memory `BaseAuthStore`).
5. Защищённые файлы (вложения) не рендерятся на сервере: их URL содержит короткоживущий токен, ему не место в HTML.
6. Изоляция проверяется e2e-тестами: параллельные SSR-запросы двух пользователей не видят данных друг друга.
### Гидратация
PocketBase SDK использует `fetch`, а не `HttpClient`, поэтому встроенный HTTP transfer cache Angular не работает. Чтобы после гидратации не загружать данные второй раз, используется `TransferState`.
## 7. URL PocketBase
- **Сервер:** `PB_URL` — переменная окружения Node-сервера, внутренний адрес PocketBase для серверного рендера (по умолчанию `http://127.0.0.1:8090`). В браузер не передаётся.
- **Браузер:** `<window.location.origin>/pb`. Префикс `/pb` — константа `PB_PUBLIC_PATH` в `shared/pocketbase/`; если путь в nginx поменяется, меняется только она.
- Всё это задаётся в одном месте: `shared/pocketbase/`. Клиент `POCKETBASE` создаётся одной и той же фабрикой и в браузере, и на сервере (`providePocketBase()` в `app.config.ts`); различается только токен `PB_URL`: `providePocketBase()` даёт браузерное значение по умолчанию, а `providePbUrlForServer()` в `app.config.server.ts` подменяет его серверным.
- **Разработка:** `ng serve` проксирует `/pb/*` → `PB_URL` со срезанием префикса (`proxy.conf.mjs`; ключ `/pb/` как у `location /pb/` в nginx, формат — Vite `server.proxy`: `target`, `changeOrigin`, `rewrite`). E2E-тесты production-сборки используют маленький тестовый фронт-прокси, повторяющий правила nginx.
### Ожидаемая конфигурация nginx (пример, деплой — вне MVP)
```nginx
location /pb/ {
proxy_pass http://127.0.0.1:8090/; # слэш в конце срезает префикс /pb
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # для realtime (SSE) PocketBase
proxy_read_timeout 360s;
client_max_body_size 60M; # один файл вложения до 50 МБ + запас
}
location / {
proxy_pass http://127.0.0.1:4000; # Node SSR-сервер
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
- Вложения загружаются по одному файлу на запрос, чтобы укладываться в `client_max_body_size`.
- В настройках PocketBase нужно указать доверенный заголовок реального IP (`X-Real-IP`), иначе лимиты запросов и логи будут видеть IP nginx.
- Админку PocketBase удобнее открывать напрямую на порту 8090 (через SSH-туннель), а не через `/pb/_/`.
## 8. Формы и UI
- Формы — Signal Forms (`@angular/forms/signals`) со схемами валидации.
- Стили — чистый CSS: дизайн-токены в CSS custom properties (`src/styles.css`), тёмная тема по `prefers-color-scheme`, никаких препроцессоров и CSS-фреймворков.
- Префикс селекторов компонентов и директив — `ksk` (`ksk-order-list`, `kskAutofocus`).
- Минимальная нагрузка на GPU — приложение должно быть лёгким на слабых телефонах и ноутбуках:
- анимируются только `transform` и `opacity`, коротко (до 200 мс); при `prefers-reduced-motion: reduce` анимации отключаются;
- без `backdrop-filter`, `filter: blur()`, больших и многослойных `box-shadow`, анимированных градиентов, `background-attachment: fixed`;
- `will-change` — только точечно и временно, не «на всякий случай»;
- тени — одна мягкая тень из токенов; глубину лучше передавать фоном поверхности и границей;
- длинные списки — `content-visibility: auto` с `contain-intrinsic-size`, чтобы не рендерить невидимое.
- Интерфейс только на русском, без i18n-фреймворка. Даты и суммы форматируются в локали `ru-RU`, валюта — рубли.
- Mobile-first: мастер часто работает с телефона.
- Доступность: AXE без ошибок, WCAG AA.
## 9. Что сознательно отложено
Подтверждение email и сброс пароля, инвайты, публичный профиль мастера, отчёты и финансы, напоминания, импорт/экспорт, Docker и деплой, мобильное приложение, бот. Архитектура их не блокирует: всё общение с данными идёт через PocketBase API и его правила.