mirror of
https://github.com/kamask-dev/pchelpmaster.git
synced 2026-11-11 05:49:38 +00:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014hvR7iG75jK5byaBBHdDLT
225 lines
21 KiB
Markdown
225 lines
21 KiB
Markdown
# Архитектура
|
||
|
||
Архитектурные решения проекта. Если код или новая задача им противоречит, сначала обсуждается и правится этот документ.
|
||
|
||
## 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`). 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 и его правила.
|