PicPac Mobile Документы Открыть приложение

PicPac Mobile

Клиент и API для заказа фотокниг и печати

Клиент и API для заказа фотокниг и печати: каталог, загрузка фото, автоматическая вёрстка, редактор, проверка качества, оформление заказа и отслеживание статуса. Рядом — совместные альбомы по ссылке и AI-помощник. Цены и права всегда считает сервер.

Expo SDK 58 Fastify 5 PostgreSQL 16 ≈ 18 минут чтения

Открыть демо →
Вход: «Продолжить как гость» или любой контакт и код 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-ассистент (классические правки и генеративное редактирование, чат по заказам и каталогу).

Что показать жюри

  1. В каталоге «Фотокниги» введите 100 BYN в поле «Подбери под бюджет». Варианты появятся автоматически. Цена одной книги без доставки рассчитывается через API.
  2. Откройте PicPac Time Machine в личной книге или общем альбоме. Выберите год, нажмите фото для перехода к развороту и добавьте разворот с датами.
  3. Скачайте 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