Открыть демо →
Вход: «Продолжить как гость» или любой контакт и код000000(DEMO_MODE).
Оплата демонстрационная — реальные деньги не списываются.
Возможности
Клиентский сценарий: вход (гость или код) → каталог и цены из API → проект из фото → автомакет или ручная раскладка → проверка качества → корзина и доставка → заказ с идемпотентностью → статусы и поддержка.
Редакторы: обложка и темы книги, шаблоны разворотов, свободные слои, фоторедактор (кадр, фильтры, текст, кисть), печать фото (формат → лента → кадрирование).
Совместная работа:
- выпускной альбом — общий макет у организатора, фото и пожелания учеников, QR на видео, именная копия после заказа;
- общий альбом — пул фото по ссылке, у каждого своя книга (
fork/sync), заказывается личная книга.
AI: согласие на обработку, генеративная правка фото, чат по каталогу и заказам; отзыв согласия в профиле.
Сотрудник: очередь заказов, смена статуса, обращения и журнал операций.
Инженерия: npm workspaces, @picpac/shared, Fastify + PostgreSQL, Expo (Android / iOS / веб). Права, цена и защита от дубля заказа — на сервере.
Модули
| Часть | Содержание |
|---|---|
packages/shared |
DTO и zod-схемы, деньги, цена, доставка, автомакет, качество, темы, сборка выпускного и общего альбомов |
api |
Вход, каталог, quote, проекты, фото, заказы, оператор, поддержка, альбомы, privacy, AI |
Приложение (app/) |
Клиентский путь, редактор книги, фоторедактор, альбомы, заказы, поддержка, AI |
| Развёртывание | Docker Compose + Caddy (HTTPS), веб на том же домене, GitHub Actions |
| Платежи | Имитация в тестовом режиме; реального списания нет |
Архитектура
┌───────────────────────────────────────────────┐
│ Приложение Expo (React Native, TypeScript) │
│ Android · iOS · веб │
│ экраны · редактор · черновик · очередь фото │
└──────────────────────┬────────────────────────┘
│ HTTPS, JWT в Authorization
┌──────────────────────▼────────────────────────┐
│ Caddy (HTTPS, раздача веб-сборки) │
│ → API (Fastify 5) │
│ auth · catalog · quote · projects · photos │
│ orders · operator · support · albums · ai │
│ privacy · audit; права на каждом запросе │
└────────────┬─────────────────────┬────────────┘
│ SQL (Drizzle) │ файлы
┌────────────▼──────────┐ ┌─────▼────────────┐
│ PostgreSQL 16 │ │ Диск: uploads/ │
│ 16 таблиц, миграции │ │ подписанные │
│ 0000–0012 │ │ ссылки, AES-GCM │
└───────────────────────┘ └──────────────────┘
@picpac/shared — общие типы, схемы и чистая логика
(цена, автомакет, качество, темы, сборка альбомов)
Права, цена и идемпотентность проверяются в коде API (а не через RLS) и покрыты тестами на реальной базе. Приложение границей безопасности не является: на каждый запрос сервер проверяет роль и доступ к объекту.
Альбомы устроены двумя способами (общая механика: ссылка-приглашение, участники, прогресс):
- Выпускной альбом — один общий макет у организатора. Ученики загружают свои фото и оставляют пожелание и ссылку на видео; «Собрать альбом» строит личные развороты, на каждом QR на видео. Заказанный альбом участник может заказать «своей именной копией» (его имя на обложке, его разворот первым).
- Общий альбом — общий пул фото. У каждого участника своя книга (
fork): фото пула копируются, книга сама раскладывается главами по дням, новые фото подтягиваются (sync). Заказывается личная книга, сам пул — нет.
Стек
| Слой | Технологии |
|---|---|
| Монорепозиторий | npm workspaces, TypeScript |
| API | Node.js 20+, Fastify 5, zod, Drizzle ORM, pg, jose (JWT HS256), @fastify/multipart, exifr, image-size, sharp |
| База | PostgreSQL 16, SQL-миграции drizzle-kit |
| Общий пакет | TypeScript-исходники без сборки, zod |
| Приложение | Expo SDK 58, React Native 0.88, Expo Router, TanStack Query, Zustand, AsyncStorage, Reanimated, @shopify/react-native-skia (фоторедактор), react-native-svg, qrcode-generator |
| Тесты | Vitest (shared, API), Jest (приложение) |
| Инфраструктура | Docker Compose (БД, API, Caddy), GitHub Actions, EAS Build для APK |
Лицензии зависимостей перечислены в LICENSES.md.
Структура репозитория
.
├── api/
│ ├── src/
│ │ ├── app.ts, server.ts, config.ts, audit.ts, errors.ts
│ │ ├── auth/ вход по коду, гость, JWT, роли
│ │ ├── db/ схема, клиент, мигратор, сид
│ │ ├── storage/ диск, шифрование, подписанные ссылки
│ │ └── modules/ catalog, quote, projects, photos, orders, operator,
│ │ support, albums, privacy, crypto, ai
│ ├── test/ тесты модулей на реальной базе
│ ├── drizzle/ SQL-миграции 0000–0012
│ └── fixtures/ тестовые фото (в том числе figma/)
├── app/
│ ├── app/ экраны (Expo Router): (auth), (tabs), catalog, project, album, orders, print, ai…
│ ├── src/ book (страницы, шаблоны, слои, QR), photoEditor, project (черновик, автосохранение),
│ │ album, orders, photos, api, auth, theme, ui, i18n
│ ├── assets/ шрифты, логотипы, примеры оформления
│ ├── app.json, eas.json конфигурация Expo и сборок
│ └── scripts/ копирование canvaskit.wasm для веба
├── packages/shared/src/ types, schemas, money, delivery, pricing, layout, quality, themes, album, event
├── docs/ сценарий демо, спецификации, заметки по интеграции
├── tools/mock-api упрощённый мок API для работы без сервера
├── scripts/ лицензии и вспомогательные скрипты
├── Caddyfile, docker-compose.yml, docker/initdb/
├── SECURITY.md политика безопасности
└── .env.example переменные окружения без значений
Роли и права
| Роль | Кто это | Что может |
|---|---|---|
| Гость | Клиент без учётной записи | Каталог, проект; для заказа нужна идентификация |
Клиент (customer) |
Частный клиент picPac.by | Свои проекты и заказы, обращения, свои фото, избранные заказы |
Оператор (operator) |
Сотрудник «Фотопринт» | Все заказы, смена статуса, журнал, ответы на обращения |
| Организатор альбома | Клиент, создавший альбом | Приглашения, пароль, сборка макета (выпускной); общий пул (общий альбом) |
| Участник альбома | Клиент по приглашению | Выпускной: только свои фото и своя запись; общий: видит все фото пула и ведёт свою книгу |
Организатор и участник — роли внутри альбома, а не глобальные. Доступ к чужому объекту возвращает 404
(а не 403), чтобы не раскрывать существование объекта, и пишется в журнал как access.denied.
403 отдаёт только проверка роли.
Пользовательский путь
| Шаг | Экран | Что происходит |
|---|---|---|
| 1 | Вход | Код на телефон или e-mail либо «Продолжить как гость» |
| 2 | Главная и каталог | Примеры оформления, линейки, цены и сроки из API |
| 3 | Создание проекта | Формат, тема оформления, автоматическая или ручная раскладка |
| 4 | Фото | Загрузка из галереи (JPEG/PNG), очередь в фоне, повтор при обрыве |
| 5 | Книга | Вкладки «Обложка / Разворот / Страницы»: листание, шаблоны разворота, «Расставить фото», свободный режим со слоями, фоторедактор |
| 6 | Проверка | Пустые ячейки блокируют заказ, низкое разрешение — предупреждение с DPI |
| 7 | Корзина | Доставка или пункт выдачи, промокод, цена пересчитывается на сервере |
| 8 | Подтверждение | Заказ создаётся один раз при повторах, деньги не списываются |
| 9 | Статус | accepted → moderation → production → ready_for_delivery → completed |
| 10 | История и поддержка | Состав, сумма, статус, обращения (с фото) по заказу, повтор и избранное |
Отдельные сценарии: печать фото (формат → лента кадров → кадрирование), выпускной альбом, общий альбом, AI-ассистент (классические правки и генеративное редактирование, чат по заказам и каталогу).
Что показать жюри
- В каталоге «Фотокниги» введите 100 BYN в поле «Подбери под бюджет». Варианты появятся автоматически. Цена одной книги без доставки рассчитывается через API.
- Откройте PicPac Time Machine в личной книге или общем альбоме. Выберите год, нажмите фото для перехода к развороту и добавьте разворот с датами.
- Скачайте PDF в размере выбранного формата из предпросмотра книги. Оформите тестовый заказ и войдите как оператор, чтобы скачать PDF из карточки заказа и сменить его статус.
Пошаговый сценарий демо и подготовка тестовых фото.
Быстрый старт
Нужны Docker, Node.js 20+ и npm. Ниже запуск API; приложение — в следующем разделе.
# 1. Зависимости
npm install
# 2. Конфигурация: заполните JWT_SECRET и FILE_URL_SECRET любыми строками для разработки
cp .env.example .env
# 3. Загрузите переменные в оболочку: API не читает .env сам
set -a; . ./.env; set +a
# 4. База: PostgreSQL на localhost:5433, базы picpac и picpac_test
docker compose up -d --wait db
# 5. Схема и тестовые данные
npm run db:migrate -w api
npm run db:seed -w api
# 6. API
npm run dev -w api
DATABASE_URL, JWT_SECRET и FILE_URL_SECRET обязательны: без них API не стартует.
Проверка: curl http://localhost:3000/health → {"status":"ok"}.
Для демо-входа включите DEMO_MODE=true в .env: код подтверждения тогда всегда 000000, письма и SMS
не отправляются. Вне демо-режима код генерируется случайно и пишется только в лог приложения.
# Токен для customer1@test.local (демо-режим)
curl -s -X POST http://localhost:3000/auth/request-code \
-H 'content-type: application/json' -d '{"contact":"customer1@test.local"}'
TOKEN=$(curl -s -X POST http://localhost:3000/auth/verify-code \
-H 'content-type: application/json' \
-d '{"contact":"customer1@test.local","code":"000000"}' | jq -r .token)
curl -s http://localhost:3000/catalog -H "authorization: Bearer $TOKEN"
Если образ postgres:16 не скачивается, задайте в .env POSTGRES_IMAGE=postgres:18. Порт базы — 5433,
чтобы не конфликтовать с локальным PostgreSQL.
Приложение: Expo, веб, APK
Адрес API берётся из EXPO_PUBLIC_API_URL, а если он не задан, из app/app.json (extra.apiUrl).
По умолчанию это https://picpac.jaraslau.dev.
Разработка на телефоне (Expo Go, если используемые нативные модули поддерживаются):
cd app
npx expo start
Откройте QR-код в Expo Go (телефон и компьютер в одной сети). Для функций с неподдерживаемыми в Expo Go нативными модулями потребуется development build или готовая сборка. Для локального API:
EXPO_PUBLIC_API_URL=http://<IP-компьютера>:3000 npx expo start.
Веб-версия:
cd app
npm run web # разработка
npm run web:build # production-сборка в app/dist (копирует canvaskit.wasm для фоторедактора)
APK через EAS (облачная сборка):
cd app
npx eas-cli@latest login
npx eas-cli@latest init # один раз: привязать проект на вашем аккаунте expo.dev
npx eas-cli@latest build -p android --profile preview # APK, API: https://picpac.jaraslau.dev
Профили в app/eas.json: development (dev-клиент), preview и production (APK с адресом API из env).
Если после eas init app.json изменился, закоммитьте extra.eas.projectId, чтобы все сборки шли в один проект.
Версии нативных пакетов должны совпадать с ожидаемыми для Expo SDK 58 (npx expo install --check):
при несовпадении сборка Android может упасть на компиляции модулей.
Фоторедактор (Skia) в вебе грузит canvaskit.wasm лениво; на телефоне нужен dev-клиент или сборка, если модуль
недоступен в Expo Go.
Развёртывание
Сервер: Docker Compose (db, api, caddy), Caddy выдаёт HTTPS и раздаёт веб-сборку из ./web; запросы,
не похожие на переходы по страницам, идут в API на том же домене, поэтому CORS не нужен.
Вручную (из корня репозитория):
git archive --format=tgz -o src.tgz HEAD # код
cd app && EXPO_PUBLIC_API_URL=https://<домен> npx expo export -p web --clear --output-dir dist-prod && cd ..
tar czf web.tgz -C app/dist-prod . # веб-сборка
# загрузить src.tgz и web.tgz на сервер, распаковать код (без .env) и веб в ./web, затем:
docker compose up -d --build api && docker compose up -d caddy
Миграции применяются автоматически при старте API. После изменения каталога (продукты, тарифы) на сервере
запустите сид: docker compose exec api node --import tsx src/db/seed.ts (идемпотентен).
Автовыкладка: .github/workflows/deploy.yml на каждый push в main прогоняет тесты, собирает веб, заливает код и
перезапускает API. Нужны секреты репозитория VPS_HOST, VPS_USER, VPS_SSH_KEY.
При AI_PROVIDER=aiai API требует FILE_ENCRYPTION_KEY и PAYLOAD_ENCRYPTION_KEY (по 64 hex) и не стартует без них.
Тестовые учётные записи и данные
Паролей нет: вход по коду, в демо-режиме код 000000.
| Учётная запись | Роль | Что видно |
|---|---|---|
customer1@test.local |
клиент | заказы и обращение из сида |
customer2@test.local |
клиент | заказы и обращение из сида |
operator@test.local |
оператор | все заказы, журнал, обращения |
«Продолжить как гость» / POST /auth/guest |
гость | новый анонимный профиль |
Сид создаёт:
- продукты каталога: фотокниги, выпускные книги, печать и цифровая печать с вариантами, ценами и сроками;
- пункты выдачи Белпочты;
- 3 промокода:
HELLO10(10%),SCHOOL15(15%),OLD20(просрочен); - заказы во всех статусах с лентой статусов, обращения и сообщения;
- тестовые фото в
api/fixtures/(в том числе низкого разрешения и с EXIF-поворотом,figma/для проверки редактора).
Сид идемпотентен: повторный запуск находит те же строки по фиксированным UUID. Все данные синтетические.
Переменные окружения
Файл .env в репозиторий не попадает; .env.example содержит только имена.
| Переменная | Обязательна | Назначение |
|---|---|---|
DATABASE_URL |
да | PostgreSQL |
DATABASE_URL_TEST |
для тестов | База тестов; имя обязано оканчиваться на _test |
JWT_SECRET |
да | Подпись токенов и кодов входа |
FILE_URL_SECRET |
да | HMAC для подписанных ссылок на фото и вложения |
FILE_ENCRYPTION_KEY |
нет* | AES-256 (64 hex) для шифрования файлов на диске |
PAYLOAD_ENCRYPTION_KEY |
нет* | AES-256 (64 hex) master для конверта клиент↔API |
UPLOAD_DIR |
нет | Папка файлов, по умолчанию ./uploads |
DEMO_MODE |
нет | true — код входа всегда 000000 (на боевом выключить) |
PORT |
нет | Порт API, по умолчанию 3000 |
PUBLIC_URL |
нет | Публичный адрес для ссылок-приглашений (алиас PUBLIC_BASE_URL устарел) |
AI_PROVIDER |
нет | stub (по умолчанию) или aiai |
AIAI_BASE_URL, AIAI_API_KEY, AIAI_EDIT_MODE |
нет | aiai.by: база (https), ключ только на сервере, режим images/chat |
AIAI_MODEL, AIAI_CHAT_MODEL |
нет | Модели фоторедактирования и чата |
POSTGRES_IMAGE |
нет | Тег образа базы для compose |
API_DOMAIN |
для деплоя | Домен(ы) для Caddy |
EXPO_PUBLIC_API_URL |
нет | Адрес API для приложения (приоритет над app.json) |
EXPO_PUBLIC_DEMO_HINT |
нет | 1 — подсказка с демо-кодом на экране входа |
* Обязательны при AI_PROVIDER=aiai.
Секреты не коммитятся. На сервере они задаются только в .env или в секретах окружения.
API
Ответы — JSON с DTO из @picpac/shared. Время — ISO 8601 в UTC с Z, суммы — целые копейки.
| Группа | Маршруты |
|---|---|
| Служебные | GET /health, GET /files/:photoId?exp&sig, GET /crypto/client-key |
| Вход | POST /auth/guest, /auth/request-code, /auth/verify-code; GET /me |
| Каталог и цена | GET /catalog, GET /catalog/:productId, POST /price-quote |
| Проекты | POST/GET /projects, GET/PUT/PATCH/DELETE /projects/:id, POST /projects/:id/duplicate |
| Фото | POST/GET /projects/:id/photos, POST /projects/:id/photos/copy, DELETE /photos/:id |
| Альбомы | POST /projects/:id/invites, POST /invites/accept, GET /projects/:id/progress, PUT /projects/:id/password, PATCH /projects/:id/members/me, POST /projects/:id/member-spread |
| Общий альбом | POST /projects/:id/fork, POST /projects/:id/sync |
| Заказы | POST /orders (+ Idempotency-Key), GET /orders, GET /orders/:id, POST /orders/:id/cancel, /favorite, /repeat |
| Оператор | GET /operator/orders, POST /operator/orders/:id/status, GET /operator/audit |
| Поддержка | POST /orders/:id/interactions, GET /interactions[/:id], POST /interactions/:id/messages, /messages/photo, GET /interactions/:id/attachments/:messageId |
| Приватность | GET /me/data-summary, DELETE /me/data |
| AI | POST /ai/consent, DELETE /ai/consent, POST /ai/edit, /ai/adjust, /ai/chat, GET /ai/jobs/:id |
Ошибки
Единый формат, message по-русски:
{
"error": {
"code": "PRICE_CHANGED",
"message": "Цена изменилась, проверьте новую сумму и подтвердите заказ",
"details": { "totalKop": 19370 }
}
}
details необязательны. Основные коды: UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST, CODE_INVALID,
CODE_EXPIRED, VARIANT_NOT_FOUND, VERSION_CONFLICT, PROJECT_ORDERED, LAYOUT_INVALID, LAYOUT_BLOCKED,
LAYOUT_PHOTO_MISSING, PAGES_OUT_OF_RANGE, PRICE_CHANGED, IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_KEY_REUSED,
POOL_NOT_ORDERABLE, ADDRESS_REQUIRED, PROMO_UNKNOWN, PROMO_EXPIRED, FILE_TOO_LARGE, FORMAT_UNSUPPORTED,
CANNOT_CANCEL, INVALID_TRANSITION, COMMENT_REQUIRED, INTERNAL.
Идемпотентность заказа
Клиент один раз придумывает UUID и передаёт его в заголовке Idempotency-Key.
- повтор с тем же телом возвращает существующий заказ и
200; - новое тело с тем же ключом даёт
422 IDEMPOTENCY_KEY_REUSED; - расхождение с
expectedTotalKopдаёт409 PRICE_CHANGEDи новую сумму; - пустые ячейки макета дают
422 LAYOUT_BLOCKED, фото не из проекта —422 LAYOUT_PHOTO_MISSING.
Статусы заказа
accepted → moderation → production → ready_for_delivery → completed
└──────────┴──→ cancelled
Оператор двигает статус ровно на один шаг. Отменить можно только из accepted или moderation, оператор —
только с комментарием. completed и cancelled конечные. Клиент может отменить заказ сам только в accepted;
при отмене проект возвращается в черновик.
Фото и макет
Только JPEG и PNG, проверка по сигнатуре файла, до 15 МБ. Размеры учитывают EXIF-ориентацию, время съёмки хранится
в UTC. Файлы лежат вне веб-корня и отдаются по ссылке /files/<id>?exp&sig, подписанной HMAC-SHA256; ссылка живёт
один час. Удаление проекта удаляет и файлы.
Макет (Layout) хранит страницы с ячейками, обложкой, темой, фоном, подписями и свободными слоями. Все новые поля
необязательны, старые макеты читаются как раньше. Сохранение идёт с проверкой версии (VERSION_CONFLICT).
Цена, доставка и деньги
Все суммы — целые копейки, валюта BYN, форматирование 87,40 BYN.
unitKop = basePriceKop + max(0, pages − pagesMin) × pricePerPageKop
itemsKop = unitKop × quantity
discountKop = round(itemsKop × discountPercent / 100)
totalKop = itemsKop − discountKop + deliveryKop
Доставка бесплатна, если сумма после скидки не меньше freeFromKop выбранного способа.
| Способ | Цена | Бесплатно от | Срок | Оплата при получении |
|---|---|---|---|---|
| Курьер по Минску | 0 | — | 1–2 дня | да |
| EMS | 20,90 BYN | 300,00 BYN | 2–4 дня | нет |
| Белпочта (в том числе пункты выдачи) | 6,90 BYN | 150,00 BYN | 3–7 дней | нет |
Пример: «Classic 20×20», 30 страниц, 2 книги, промокод HELLO10, EMS. 8100 + 10 × 150 = 9600 за книгу,
19200 за две, скидка 1920, доставка 2090, итог 19370 копеек — 193,70 BYN.
Тесты
npm test -w @picpac/shared # цена, доставка, автомакет, качество, схемы, сборка альбомов
npm test -w api # модули API на реальной базе
npm test -w app # Jest: логика приложения (шаблоны, слои, макет, альбомы, синхронизация)
npx tsc --noEmit -p api # типы API
(cd app && npx tsc --noEmit) # типы приложения
790 автотестов — 384 в приложении, 303 в API и 103 в общей логике (shared).
Проверка типов прошла. В последнем полном прогоне один тест журнала оператора упал и прошёл при отдельном повторном запуске.
Тесты API требуют запущенного PostgreSQL и базу picpac_test (её создаёт docker/initdb/01-test-db.sql).
Перед запуском применяются миграции, а таблицы очищаются перед каждым тестом. Защита не даст очистить базу, имя
которой не оканчивается на _test. Фикстуры можно пересоздать: npm run fixtures -w api.
Документация
| Документ | О чём |
|---|---|
| Сценарий демо | Основной путь и дополнительные сценарии на 5–7 минут |
| Отчёт о тестировании | Покрытие сценариев и чем оно подтверждено |
| Дизайн | Визуальная спецификация |
| UX/UI | Экраны и жесты |
| Заметки по интеграции | Как переключить приложение на другой API |
SECURITY.md |
Политика безопасности |
LICENSES.md |
Лицензии зависимостей (npm run licenses) |
AGENTS.md |
Гайд для агентов по стеку и правилам репозитория |
Ограничения
- Оплата имитируется. Платёжного шлюза и ЕРИП нет, заказ всегда тестовый, деньги не списываются.
- Нет отправки SMS и писем. Код входа пишется в лог; в демо —
000000. До продакшена нужен лимит попыток и реальный канал. - Нет push-уведомлений. Статус меняет оператор через API, клиент узнаёт его опросом.
- Фото на локальном диске. Для нескольких инстансов нужно объектное хранилище; в общем альбоме файлы копируются в каждую личную книгу.
- Права в коде, без RLS. Проверки покрыты тестами, но политики базы не дублируют их.
- AI. Без
AIAI_API_KEYработают заглушки; с ключом — aiai.by. Конверт клиент↔API и шифрование файлов включаются ключамиPAYLOAD_ENCRYPTION_KEYиFILE_ENCRYPTION_KEY. - Общий альбом: книга собирается главами по дням съёмки (UTC); сам пул не заказывается — только личная книга участника.
Исходник страницы — README.md в репозитории проекта.
README.md