Files
pchelpmaster/docs/architecture.md
T

21 KiB
Raw Blame History

Архитектура

Архитектурные решения проекта. Если код или новая задача им противоречит, сначала обсуждается и правится этот документ.

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-токен.

// 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)

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 и его правила.