Skip to content
4booserPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Shifter

Учёт смен для тех, кому платят по часам: календарь, куда смены накидываются как кисточкой, подсчёт заработка с переработками, чаевыми и удержаниями, и статистика, которая показывает, сколько на самом деле стоит один рабочий день.

ASP.NET Core (.NET 10) + React 19 / Next.js 15 / Tailwind 4, PostgreSQL, всё поднимается одной командой.


Содержание


Быстрый старт

Нужен только Docker.

cp .env.example .env
# впиши ключ подписи: openssl rand -base64 48
docker compose up --build

Что произойдёт по шагам:

  1. поднимется PostgreSQL, создадутся базы shifter и tokens;
  2. compose дождётся, пока база реально начнёт отвечать (healthcheck, а не «sleep 5»);
  3. отдельный контейнер накатит миграции на обе базы и завершится;
  4. соберётся фронтенд, соберётся API, и приложение стартует.

Открывай http://localhost:8080 — там и SPA, и API.

docker compose down        # остановить
docker compose down -v     # остановить и стереть базу
docker compose logs -f     # смотреть логи

Структура репозитория

server/                    бэкенд целиком
  Shifter.csproj
  appsettings.json         без секретов, лежит в гите
  appsettings.Development.json   локальные значения для dotnet run
  src/Api                  контроллеры, middleware, Program.cs
  src/Application          фичи, DTO, обработчики, бизнес-правила
  src/Domain               сущности и чистые расчёты
  src/Infrastructure       EF Core, репозитории, миграции
  wwwroot/                 сюда web кладёт статический экспорт (генерируется)

web/                       фронтенд: Next.js (статический экспорт) + Tailwind
  src/lib                  API-клиент с refresh, сторы, чистая логика, i18n
    calendar/holidays.ts   праздники считаются в приложении, без сети
    export/ics.ts          выгрузка смен в календарь телефона
  src/app                  dashboard, schedule, stats, payouts, team, wrapped,
                           webhooks, account, login, register
  src/components           календарь, панель дня, модалки, графики, UI-кит
  public/sw.js             киллер старого service worker'а
  tests/                   vitest: даты, праздники, прогноз, экспорт, импорт

tests/Shifter.Tests        xUnit: расчёты, доступ, приватность общего графика

docker/
  init-databases.sh        создаёт вторую базу при первом старте PostgreSQL
  Caddyfile                конфиг прокси на проде, весь в одну строку адреса
  bootstrap.sh             подготовка чистой VPS под деплой

.github/workflows/
  ci.yml                   тесты на каждый push и pull request
  deploy.yml               сборка образов и выкат прода с main

Dockerfile                 стадии: client → build → migrate → final
compose.yaml               локально: db + migrate + shifter, всё собирается на месте
compose.prod.yaml          на сервере: те же сервисы + Caddy, образы из реестра

Бэкенд и фронтенд лежат на одном уровне: server/ и web/. Корень содержит только то, что относится к обоим сразу.


Разработка без Docker

База в контейнере, приложение локально — так работает горячая перезагрузка.

docker compose up -d db          # только PostgreSQL

cd server && dotnet run          # API на http://localhost:5208
cd web && npm run dev            # SPA на http://localhost:4200

Dev-сервер Next проксирует всё, что начинается с /shifter, на порт 5208 (см. rewrites в web/next.config.ts), так что CORS в разработке не нужен.

Если PostgreSQL стоит прямо в системе, а не в Docker, роль и базы создаются так:

CREATE ROLE shifter_user WITH LOGIN PASSWORD '0000';
CREATE DATABASE shifter OWNER shifter_user;
CREATE DATABASE tokens  OWNER shifter_user;

Ошибка 28P01 при входе означает ровно одно: PostgreSQL не принял пароль — либо роли нет, либо пароль другой. Лечится строчкой выше или ALTER ROLE shifter_user WITH PASSWORD '0000';.


Переменные окружения

Ничего секретного в репозитории нет. Порядок приоритета: переменная окружения → user-secrets → appsettings.Development.json → appsettings.json.

Переменная Зачем Обязательна
SHIFTER_JWT_KEY ключ подписи токенов, минимум 32 байта да
ConnectionStrings__Shifter основная база да вне разработки
ConnectionStrings__Tokens база refresh-токенов да вне разработки
Google__ClientId OAuth-клиент веба; пусто — кнопка Google скрыта везде нет
Google__IosClientId OAuth-клиент iOS-приложения; пусто — кнопки Google на iPhone нет нет
Google__AndroidClientId OAuth-клиент Android-приложения; пусто — кнопки Google на Android нет нет
Cors__AllowedOrigins__0 origin для SPA с другого домена нет
POSTGRES_PASSWORD пароль роли в compose нет, по умолчанию 0000

Без ключа подписи Release-сборка не стартует, а падает с внятным сообщением — это специально, чтобы не оказаться в проде с пустой подписью.

Debug-сборка ведёт себя иначе. Если запустить бинарник напрямую из bin/Debug, launchSettings.json не читается и среда считается Production, хотя это очевидно машина разработчика. Поэтому Debug-сборка при отсутствии настроек берёт localhost и встроенный ключ разработки, громко предупреждая об этом в логе:

[WRN] No 'Shifter' connection string configured; falling back to localhost/shifter.
[WRN] No signing key configured; using the built-in development key.

Release-сборка ничего этого не получает.

Сгенерировать ключ:

openssl rand -base64 48

Для локальной разработки удобнее user-secrets — файл лежит вне репозитория:

cd server
dotnet user-secrets init
dotnet user-secrets set "TokenOptions:Key" "$(openssl rand -base64 48)"
dotnet user-secrets list

Базы данных и миграции

Их две и они намеренно разные: рабочие данные и refresh-токены не должны уезжать вместе в один дамп.

База Контекст Что внутри
shifter ShifterDbContext пользователи, дни, смены, места, продажи, выплаты
tokens TokensDbContext refresh-токены (только хеши)

В Docker миграции накатываются сами. Вручную — с явными строками подключения, потому что dotnet ef не читает launchSettings.json и стартует как Production:

cd server
export ConnectionStrings__Shifter="Host=localhost;Port=5432;Database=shifter;Username=shifter_user;Password=0000"
export ConnectionStrings__Tokens="Host=localhost;Port=5432;Database=tokens;Username=shifter_user;Password=0000"

dotnet ef database update --context ShifterDbContext
dotnet ef database update --context TokensDbContext

Новая миграция:

dotnet ef migrations add ИмяМиграции --context ShifterDbContext \
    --output-dir src/Infrastructure/Persistence/Migrations

Дальше — два правила, каждое закреплено тестом, который упадёт, если правило нарушить.

Значение по умолчанию — это ответ, а не пустое место

Колонка NOT NULL, добавленная к таблице, где уже есть строки, получает то, что написано в defaultValue. EF предлагает ноль типа: "", 0, 0001-01-01, — и это почти никогда не то, что приложение готово принять обратно.

В этом проекте так уже случалось трижды. Пустой цвет ушёл в атрибут стиля и нарисовал место никак. День выплаты 0 не проходит проверку 1..28, то есть место нельзя пересохранить, а «когда придут деньги» считается от несуществующего дня. И худшее: OvertimeMultiplier = 0 при формуле часы × ставка × (множитель − 1) — то есть каждый сверхурочный час вычитал часовую ставку и показывал убыток как «переработки принесли».

MigrationDefaultsTests проходит по всем миграциям и падает на нуле, которому никто не написал оправдания. Осознанный ноль добавляется в словарь с одной строкой «почему» — писать её приходится руками, и в этом вся суть.

Сначала расширяем, потом выпускаем, потом сужаем

Деплой сначала накатывает миграции и только потом подменяет контейнер. Значит всё это время — минуту, иногда несколько — предыдущий релиз работает с новой схемой. Переименованная или удалённая колонка в этом окне даёт 500 на каждый запрос, который её трогает, и расположением шагов деплоя это не лечится.

Лечится тремя выпусками вместо одного:

  1. Добавить новую колонку, писать в обе. Ничего не удаляется.
  2. Выпустить код, который читает новую.
  3. Убрать старую — когда не осталось ничего, что её читает.

MigrationSafetyTests следит за RenameColumn, RenameTable, DropColumn и DropTable в Up. Семь уже выпущенных перечислены поимённо: миграцию, которая где-то отработала, править нельзя, а притворяться, что прошлое было чистым, — не стоит. Каждая из них клала сайт на длину деплоя, чего никто не заметил, потому что никто не смотрел.


API

Базовый путь /shifter/v1. Всё, кроме помеченного «аноним», требует заголовок Authorization: Bearer <access_token>.

userId всегда берётся из claims токена и никогда из тела запроса.

Swagger в режиме разработки: http://localhost:5208/swagger

Аутентификация — /shifter/v1/auth

Метод Путь Аноним Что делает
POST /user/register ✓ регистрация, сразу отдаёт пару токенов
POST /user/login ✓ вход по логину и паролю
POST /refresh ✓ меняет refresh-токен на новую пару
POST /google ✓ вход или регистрация по Google ID-токену
GET /google/config ✓ отдаёт client_id, чтобы клиент решил, рисовать ли кнопку
POST /logout ✓ гасит текущий refresh-токен
POST /logout/all гасит все сессии на всех устройствах
GET /me id и логин из токена
GET /goal месячная цель по доходу
PUT /goal задать цель

Весь контроллер под жёстким лимитом: 10 запросов в минуту с адреса.

Refresh работает с ротацией: предъявленный токен гасится, выдаётся новый. Если тот же токен предъявят повторно — значит он утёк, и тогда гасятся все сессии пользователя, а не только эта.

Примеры
# регистрация
curl -X POST http://localhost:8080/shifter/v1/auth/user/register \
  -H 'Content-Type: application/json' \
  -d '{"first_name":"Аня","last_name":null,"login":"anya","password":"secret123"}'

# → {"access_token":"eyJ...","refresh_token":"...","access_expires_at":"..."}

# вход
curl -X POST http://localhost:8080/shifter/v1/auth/user/login \
  -H 'Content-Type: application/json' \
  -d '{"login":"anya","password":"secret123"}'

# обновление пары
curl -X POST http://localhost:8080/shifter/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refresh_token":"..."}'

Аккаунт — /shifter/v1/account

Метод Путь Что делает
GET / профиль: логин, имя, есть ли пароль, привязан ли Google
PUT / сменить имя и фамилию
PUT /password сменить пароль; заодно гасит все остальные сессии
DELETE /google отвязать Google (нельзя, пока нет пароля)
DELETE / удалить аккаунт со всеми данными

Удаление требует ввести свой логин дословно, а при наличии пароля — ещё и пароль.

Места работы — /shifter/v1/locations

Метод Путь Что делает
GET /locations?archived=true список мест
POST /locations создать
PUT /locations/{id} изменить
POST /locations/{id}/archived архивировать или вернуть
DELETE /locations/{id} удалить; откажет с 409, если на месте висят смены

У места живут все правила расчёта: расчётный период, порог переработки и коэффициент, процент tip-out от чая и от продаж, стоимость питания, ставка налога и признак «чаевые тоже облагаются», процент отпускных и валюта.

Шаблоны смен — /shifter/v1/shifts

Метод Путь Что делает
GET /shifts?archived=true палитра шаблонов
POST /shifts создать шаблон
PUT /shifts/{id} изменить
POST /shifts/{id}/archived архивировать или вернуть

Шаблон несёт время начала и конца, неоплачиваемый перерыв, ставку и период оплаты (hour, day, week, month).

Позиции продаж — /shifter/v1/sales

Метод Путь Что делает
GET /sales?archived=true список позиций
POST /sales создать
PUT /sales/{id} изменить цену или процент
POST /sales/{id}/archived архивировать или вернуть
DELETE /sales/{id} удалить; откажет с 409, если позиция уже в записанных днях

Дни — /shifter/v1/days

Метод Путь Что делает
GET /days?from=2026-03-01&to=2026-03-31 дни и все итоги за период
PUT /days/{date} сохранить день целиком
POST /days/bulk накинуть или снять шаблон сразу на много дат

День сохраняется целиком, а не патчами: клиент присылает полный состав смен, продаж, чаевых и удержаний.

Ответ GET /days
{
  "days": [ /* по дню на дату, где что-то есть */ ],
  "hours": 168.5,
  "planned_hours": 24,
  "shifts_earned": 42000,
  "sales_earned": 3150,
  "tips_earned": 18000,
  "period_earned": 0,        // недельные и месячные оклады, посчитанные раз за период
  "total_earned": 61150,
  "planned_earned": 6000,
  "days_worked": 21,
  "days_planned": 3,
  "paid": 60000,
  "difference": -1150,       // отрицательное — недоплатили
  "tip_out": 1800,
  "deductions": 3150,
  "tax": 8400,               // удержано у источника
  "net_earned": 52750,       // total_earned минус налог
  "holiday_accrued": 4200,   // причитается позже, ни в один итог не входит
  "currencies": ["PLN"],     // больше одной — итоги смешаны, читать по местам
  "overtime_hours": 8.5,
  "overtime_earned": 1275,
  "by_location": [ /* разбивка по местам */ ]
}

Выплаты — /shifter/v1/payouts

Метод Путь Что делает
GET /payouts?from=&to= полученные выплаты за период
POST /payouts записать выплату; location_id говорит, от кого она
DELETE /payouts/{id} удалить
GET /payouts/schedule?from=&to= календарь выплат и детектор недоплаты

schedule разворачивает расчётные периоды каждого места, считает, сколько должно прийти на руки, сверяет с записанными выплатами и помечает статусом: open — период ещё идёт, due — ждём, overdue — срок прошёл, paid — сошлось, short — недоплата, over — переплата. Отдельно возвращает shortfalls: места, недоплатившие два периода подряд и больше. Один короткий период — спор о округлении, три подряд — повод идти к тому, кто считает зарплату.

Нужны для сверки: приложение считает, сколько должны, и сравнивает с тем, сколько пришло.

Вебхуки — /shifter/v1/webhooks и /shifter/v1/hooks/{token}

Чтобы день заполняла не рука, а касса, выгрузка графика или сценарий в Make.

Метод Путь Аноним Что делает
GET /webhooks свои эндпоинты, с адресом, ключом и счётчиками за неделю
POST /webhooks создать; адрес и ключ выдаются сразу
PUT /webhooks/{id} имя, тип, вкл/выкл, шаблон по умолчанию, маппинг
POST /webhooks/{id}/token новый адрес и новый ключ разом; старый URL умирает
DELETE /webhooks/{id} удалить вместе с журналом; записанные дни остаются
GET /webhooks/{id}/deliveries последние 50 доставок с телами как пришли
POST /webhooks/deliveries/{id}/replay прогнать сохранённое тело ещё раз
POST /webhooks/{id}/test?apply= разобрать вставленный payload; по умолчанию ничего не пишет
POST /hooks/{token} ✓ приём: единственная запись без нашего токена

Тип эндпоинта — sales (выручка за день), hours (отработанные часы) или both (и то и другое из одного тела) — и это единственное, что он может писать. Утёкший ключ от кассы, заведённой как sales, не тронет график.

both — для ночного отчёта, который несёт и проданное, и длину смены: два адреса под один отчёт означают два ключа и два расписания, которые надо держать в согласии. Каждая половина пишется, только если она в теле действительно есть, поэтому отчёт с одними продажами не выдумает смену из шаблона. Исключение — эндпоинт hours: там «сегодня работал, смена обычная» это законченное утверждение, и времена берутся из шаблона.

Кто пишет. Отправитель аккаунта не имеет, поэтому доказательство одно: знает ли он ключ эндпоинта.

# просто: ключ в заголовке — для софта, у которого есть только поле «URL»
curl -X POST https://host/shifter/v1/hooks/$TOKEN \
  -H 'Content-Type: application/json' \
  -H "X-Shifter-Secret: $SECRET" \
  -d '{"date":"2026-08-20","tips":42.50,"sales":[{"name":"Вино","quantity":3}]}'

# с подписью: HMAC-SHA256 ключом по строке "{timestamp}.{body}"
BODY='{"date":"2026-08-20","tips":42.50}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -X POST https://host/shifter/v1/hooks/$TOKEN \
  -H 'Content-Type: application/json' \
  -H "X-Shifter-Timestamp: $TS" \
  -H "X-Shifter-Signature: sha256=$SIG" \
  -d "$BODY"

Подпись живёт пять минут: время входит в подписываемую строку, поэтому перехваченное тело нельзя переслать завтра. Выключенный эндпоинт отвечает ровно как несуществующий — 404 на оба, иначе адрес превращается в способ перебирать токены. Тело больше 256 КБ не читается.

Если отправитель подписывает по-своему. Касса или дашборд редко дают выбрать заголовок: они шлют собственный ключ под собственным именем. Впишите в эндпоинт имя заголовка и ключ отправителя — и он продолжит подписывать как умеет:

X-Syrve-Signature: t=1787600865,v1=c5dba20b95cf3f3e…

Читается распространённый вид t=<unix>,v1=<hex> — HMAC-SHA256 по строке {t}.{body}, время внутри самой подписи. Несколько v1= в одном заголовке допустимы: так отправитель меняет ключ, не теряя доставки. Ключ вида whsec_… пробуется и как строка, и как байты за префиксом — две распространённые конвенции делят один формат, и по запросу не отличить, какая из них в ходу.

Свой ключ эндпоинта при этом продолжает работать: одна интеграция ходит своей схемой, скрипт или вторая — нашей.

Что писать. Канонический вид; поля, которых нет, не трогаются — доставка с одними чаевыми не сотрёт заметку.

// sales
{
  "date": "2026-08-20",       // ISO, ISO-время или unix; смещение уважается
  "external_id": "till-991",  // повтор с тем же id узнают, а не посчитают дважды
  "tips": 42.50, "tips_cash": 15, "deductions": 0, "note": "запара",
  "replace": false,           // true — доставка владеет днём целиком
  "sales": [{ "name": "Вино", "quantity": 3 }]   // или "sales_id"
}

// hours
{
  "date": "2026-08-20", "shift": "Вечер",   // имя шаблона смены; из него ставка
  "start": "17:00", "end": "23:30", "break_minutes": 30,
  "hours": 6.5,        // вместо start/end, если отправитель считает только часы
  "worked": true
}

sales принимается и списком, и объектом «название: количество» — второе есть у сводок, которые человек пишет руками:

{ "date": "2026-08-24", "sales": { "Heven": 2, "Maduro": 2, "Adalya": 1 } }

Позиции сопоставляются с каталогом по имени, цена и процент снимаются копией — переоценка каталога не переписывает заработанное. Одно имя, названное в доставке несколько раз, складывается: касса перечисляет позицию раз на заказ, а не раз на вечер. Если в каталоге нет чего-то из доставки, в ошибке перечисляются все недостающие имена сразу: ночной отчёт называет всё проданное, и разбирать его по одному имени за попытку — это вечер работы.

Если отправитель говорит по-своему — маппинг на эндпоинте, без релиза:

{
  "$root": "data.object",          // пропустить конверт из id и типа события
  "date": "closed_at",
  "tips": "totals.tip_money",
  "sales": "line_items",           // массив
  "sales.name": "catalogue.name",  // читается внутри каждого элемента
  "sales.quantity": "qty",
  "$divide": { "tips": 100 },      // отправитель считает в центах
  "worked": "=true"                // константа для поля, которого у него нет
}

Доставка, в которой ничего не совпало — поля позиций нет вовсе, сумм тоже, — отбивается с 400. Отправитель показывает 2xx как «доставлено», и маппинг, который никуда не указывает, иначе неделю выглядит рабочим при пустом календаре.

Отчёт по расписанию приходит и в выходные: shiftHours: 0 и нули по всем позициям. Это утверждение о дне, а не поломка — ноль часов означает «смены не было», смена не ставится, позиции с нулём не пишутся, день не создаётся, статус empty. Отдельный случай — replace: true с пустым списком: это явная команда «день пуст», и она день действительно очищает.

Доставка, в которой поле позиций было и оказалось пустым, — не создаёт пустой день и получает статус empty, а не applied: кнопка «тест» у отправителя выдаёт ровно её, и отчёт об успехе отправил бы человека искать в календаре день, которого никто не писал. Ноль — это цифра, поэтому день с нулевой выручкой пишется как обычно.

Каждая доставка попадает в журнал вместе с телом — включая отклонённые. В отказе из-за подписи перечисляются имена заголовков, которые отправитель реально прислал (только имена, не значения): отправитель, подписавший под своим именем, снаружи неотличим от не подписавшего вовсе. В этом и смысл: маппинг правится утром, а ночь возвращается через replay, а не теряется. Журнал хранит по 100 последних доставок на эндпоинт.

Что делает клиент поверх этого

Возможность Как работает
Поиск один запрос за всю историю, ищет по заметкам, названиям смен и датам
Отмена снимок затронутых дней до изменения; один шаг назад
Импорт CSV и XLSX разбираются в браузере, предпросмотр, запись после подтверждения
Онбординг чек-лист из трёх шагов, отмечается сам по реальному состоянию

Служебное

Метод Путь Аноним Что делает
GET /health ✓ проверяет обе базы; для балансировщика

Ошибки

Один формат на все случаи:

{ "status": 400, "error": "ValidationException", "message": "Start and end time must differ." }
Код Когда
400 не прошла валидация
401 нет токена, протух, отозван
403 не твой ресурс
404 нет такого или принадлежит другому — ответ одинаковый специально
409 удаление того, на что ссылается история
429 превышен лимит запросов, смотри заголовок Retry-After

Тесты

dotnet test Shifter.sln                  # 142 теста: расчёты, доступ, валидация
cd web && npm test                       # vitest: даты, праздники, экспорт, импорт

Бэкенд-тесты идут без базы, на подменах репозиториев. Покрыто то, что нельзя сломать молча: границы расчётных периодов, порог переработки, оклад за период, tip-out, питание, разнос чая и продаж между двумя местами за один день, диапазоны событий, приватность общего графика и правила подмены смен — кто может предложить себя, кто отдать и что нельзя отдать дважды.

Клиентские проверяют то, что ломается тихо и не в тестах, а у людей: даты праздников сверены с опубликованными календарями, свёртка и экранирование в .ics (иначе телефон просто молча не импортирует файл), и шаг цветовой ротации через границу месяца.

CI гоняет обе половины на каждый push и pull request — .github/workflows/ci.yml.


Деплой

Прод живёт на https://shifter.ink. Ручного выката нет: любой push в main проходит тесты, собирает образы, кладёт их в GHCR и перезапускает стек на сервере. Всё это — .github/workflows/deploy.yml.

push в main
   │
   ├─ test      dotnet test + vitest + tsc — падение здесь останавливает всё
   ├─ build     два образа из одного Dockerfile, теги :sha и :latest → ghcr.io
   └─ deploy    scp compose-файлов в /opt/shifter
                docker compose pull && up -d      ← миграции накатываются сами
                перечитывание конфига прокси, если он изменился
                ожидание healthy — иначе ран падает и показывает логи

Сервер ничего не собирает, и это осознанно: машина, которая компилирует то, что на ней же и запускается, однажды уронит выкат по причине, которую никто не увидит. Он только скачивает готовые образы, проверенные CI.

Как выкатить изменения

git push origin main

Это всё. Ничего на сервере запускать не нужно — ни миграций, ни рестартов.

Посмотреть, как идёт:

gh run watch                       # текущий выкат
gh run list -w Deploy -L 5         # последние пять

Миграции едут сами: контейнер migrate стартует раньше приложения и завершается до того, как оно поднимется. Если миграция упадёт, приложение просто не стартует — старая версия продолжит работать, а не наполовину обновлённая.

Если ран упал на Failed to download action … 429 — это codeload GitHub, а не код. Лечится gh run rerun <id> --failed: успешные джобы не пересобираются.

Что происходит с сайтом во время выката

Реплика одна, поэтому старый контейнер останавливается раньше, чем новый начинает отвечать, — примерно полминуты. Раньше в это окно каждый запрос получал 502. Сейчас Caddy держит соединение и отвечает, когда приложение поднимется (lb_try_duration в docker/Caddyfile), так что запрос ждёт дольше обычного, но доезжает. Замерено на живом сайте перезапуском под нагрузкой:

200 ошибок
было 73 51 × 502
стало 62 0, худший запрос 29 с

Совсем убрать паузу можно только второй репликой приложения — это другой разговор.

Откатиться на предыдущую версию

Образы тегируются коммитом, так что откат — это подстановка старого тега.

Сначала посмотрите, на что вообще есть чем откатиться. Деплой держит четыре последних образа по счёту, а не по возрасту: раньше чистилось «старше 48 часов», и после недели без выкатов откатываться было не на что вовсе, а после шести выкатов за день — на девять версий назад.

ssh -i ~/.ssh/shifter_deploy deploy@СЕРВЕР
cd /opt/shifter

docker compose -f compose.prod.yaml ps --format '{{.Service}} {{.Image}}'   # что сейчас
docker image ls ghcr.io/4booser/shifter --format '{{.Tag}}  {{.CreatedAt}}'  # что есть

Если нужного тега на сервере уже нет — его надо забрать из реестра, а пакеты приватные, так что сначала вход. Токен — personal access token с правом read:packages; он нужен только на время отката:

echo "<TOKEN>" | docker login ghcr.io -u <ваш-github-логин> --password-stdin

echo "TAG=<нужный sha>" >> .env.release        # или поправить существующую строку
docker compose -f compose.prod.yaml --env-file .env --env-file .env.release pull
docker compose -f compose.prod.yaml --env-file .env --env-file .env.release up -d

docker logout ghcr.io

Если образ на сервере есть, pull и вход не нужны — хватит up -d.

Осторожно с миграциями: откат кода не откатывает схему. Если между версиями была миграция, старое приложение встретит новую таблицу — обычно это переживается, но добавленную NOT NULL-колонку уже нет. Проверяйте, что именно откатываете.

Стек на сервере

compose.prod.yaml — четыре сервиса:

Сервис Что делает
db PostgreSQL 17, том shifter-db, наружу не публикуется
migrate накатывает миграции на обе базы и завершается; приложение ждёт его успеха
shifter API и SPA одним контейнером, слушает 8080 только внутри сети
proxy Caddy, единственный, кто смотрит наружу: порты 80 и 443

Образы shifter и shifter-migrate собираются из одного Dockerfile, разных стадий, и получают один и тот же тег коммита — так выкат не может свести приложение с чужой схемой базы.

Новый сервер с нуля

Нужен доступ по SSH и ключ, которым будет ходить CI.

# 1. ключ на сервер (пароль спросят в последний раз)
ssh-keygen -t ed25519 -f ~/.ssh/shifter_deploy -C shifter-deploy
ssh-copy-id -i ~/.ssh/shifter_deploy.pub root@СЕРВЕР

# 2. подготовка машины
scp -i ~/.ssh/shifter_deploy docker/bootstrap.sh root@СЕРВЕР:/root/
ssh -i ~/.ssh/shifter_deploy root@СЕРВЕР 'bash /root/bootstrap.sh'

bootstrap.sh идемпотентен и знает про Arch и Debian/Ubuntu — то, что реально выдают провайдеры. Он ставит Docker с compose, заводит пользователя deploy (в группе docker и больше нигде — деплою не нужен root), создаёт /opt/shifter, поднимает ufw на 22/80/443, включает fail2ban, добавляет 2 ГБ swap на машинах меньше 2 ГБ памяти и выключает вход по паролю — но только если ключ уже на месте, иначе оставит пароль включённым и скажет об этом. Дверь не запирается, пока ключ внутри.

# 3. секреты .env — создаются на сервере один раз и в гит не попадают
ssh -i ~/.ssh/shifter_deploy deploy@СЕРВЕР
cd /opt/shifter
umask 077
cat > .env <<EOF
SHIFTER_JWT_KEY=$(openssl rand -base64 48)
POSTGRES_PASSWORD=$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 32)
SITE_ADDRESS=shifter.ink, www.shifter.ink
EOF

Пока A-запись домена ещё не смотрит на сервер, вместо имён ставится :80 — Caddy отдаёт голый HTTP по IP и не пытается получить сертификат, который ему всё равно не выдадут. Домен вписывается позже, см. «Домен и HTTPS».

Пароль базы — только буквы и цифры, намеренно. Он подставляется в строку подключения вида Host=db;…;Password=…, и ; внутри разорвал бы её молча, дав ошибку аутентификации вместо ошибки конфигурации.

.env живёт на сервере и нигде больше. CI его не пишет и не перезаписывает — выкат приносит только определение стека, но не секреты.

# 4. секреты репозитория, которыми CI ходит на сервер
gh secret set DEPLOY_SSH_KEY < ~/.ssh/shifter_deploy
gh secret set DEPLOY_HOST --body СЕРВЕР
gh secret set DEPLOY_USER --body deploy

# 5. и всё: выкат — это push
git push origin main

Логин в GHCR внутри выката делается токеном самого рана, который истекает вместе с ним. Постоянных учёток реестра на сервере не хранится, а образы остаются приватными.

Что открыто наружу

Порт Снаружи Что там
22 открыт SSH, только по ключу, пароли выключены, fail2ban
80 открыт Caddy: уводит на https 308-м и отвечает на проверки Let's Encrypt
443 открыт тот же Caddy под TLS; сертификаты Let's Encrypt на shifter.ink и www.shifter.ink
5432 закрыт база не публикуется вовсе; ходить в неё — через SSH-туннель
8080 закрыт приложение доступно только прокси

Одна тонкость, о которую легко споткнуться: Docker публикует порты своей цепочкой iptables и проходит мимо ufw. То есть база приватна не потому, что файрвол закрыт, а потому, что compose.prod.yaml её не публикует. Стоит дописать туда ports: 5432:5432 — и она окажется в интернете, а правила ufw этому не помешают. По той же причине на сервере нельзя запускать обычный compose.yaml: он публикует Postgres и собирает образы прямо на машине.

Заглянуть в базу, ничего не открывая наружу:

# psql прямо в контейнере
ssh -i ~/.ssh/shifter_deploy deploy@СЕРВЕР \
    'cd /opt/shifter && docker compose -f compose.prod.yaml exec db psql -U shifter_user shifter'

Клиенту с графикой нужен туннель. Порт 5432 на хосте не слушает никто, поэтому пробрасывать надо на адрес контейнера — ssh резолвит его уже на сервере:

DB=$(ssh -i ~/.ssh/shifter_deploy deploy@СЕРВЕР \
     "docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' shifter-db-1")

ssh -i ~/.ssh/shifter_deploy -N -L 5433:$DB:5432 deploy@СЕРВЕР
# теперь база на localhost:5433, пароль — POSTGRES_PASSWORD из /opt/shifter/.env

Эксплуатация

ssh -i ~/.ssh/shifter_deploy deploy@СЕРВЕР
cd /opt/shifter

docker compose -f compose.prod.yaml ps               # состояние
docker compose -f compose.prod.yaml logs -f shifter  # логи приложения
docker compose -f compose.prod.yaml logs -f proxy    # доступы и выдача сертификатов
docker compose -f compose.prod.yaml restart shifter

Заглянуть в базу, ничего не открывая наружу:

docker compose -f compose.prod.yaml exec db psql -U shifter_user shifter

docker compose down -v стирает том с базой. На проде этой команде делать нечего.

Выкатить руками, если CI лёг

ssh -i ~/.ssh/shifter_deploy deploy@СЕРВЕР
cd /opt/shifter

# Образы приватные, поэтому нужен логин: PAT с правом read:packages.
echo "$GHCR_TOKEN" | docker login ghcr.io -u ВАШ_ЛОГИН --password-stdin

docker compose -f compose.prod.yaml --env-file .env --env-file .env.release pull
docker compose -f compose.prod.yaml --env-file .env --env-file .env.release up -d
docker logout ghcr.io

Правки в конфиге прокси

docker/Caddyfile смонтирован в контейнер как один файл, а файл в bind-mount привязан к иноду. scp файл не переписывает, а заменяет, поэтому на хосте оказывается новая версия, а контейнер продолжает читать старую — и caddy reload честно отвечает «config is unchanged». Ровно так однажды несколько дней прожила «применённая» правка, которой не было.

Поэтому выкат сравнивает то, что реально видит контейнер, с тем, что отправлено, и пересоздаёт прокси при расхождении. Руками — то же самое:

docker compose -f compose.prod.yaml exec -T proxy cat /etc/caddy/Caddyfile | diff - docker/Caddyfile
docker compose -f compose.prod.yaml --env-file .env --env-file .env.release up -d --force-recreate proxy

Проверять конфиг до выката — бесплатно и спасает от прокси, который не поднялся:

docker run --rm -e SITE_ADDRESS=":80" \
    -v "$PWD/docker/Caddyfile:/etc/caddy/Caddyfile:ro" \
    caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile

«Правки не появились на сайте»

Сначала проверьте, что дело не в сервере:

# какой коммит крутится
ssh -i ~/.ssh/shifter_deploy deploy@СЕРВЕР \
    'cd /opt/shifter && docker compose -f compose.prod.yaml ps --format "{{.Service}} {{.Image}}"'

# что реально отдаётся браузеру
curl -s https://shifter.ink/ | grep -oE 'main-[A-Z0-9]+\.js'
curl -s https://shifter.ink/main-XXXXXXXX.js | grep -c 'какая-нибудь новая строка'

Если тег совпадает с последним коммитом, а в бандле новый код есть — сервер ни при чём, и старую версию держит браузер. Это уже случалось: не выставлялся Cache-Control, и браузер по эвристике кешировал sw.js; устаревший service worker не перезапрашивался, не обновлялся и продолжал отдавать бандл, который закешировал в первый раз. Сайт выглядел замороженным, пока сервер выкатывал новые релизы.

Теперь точки входа отдаются с no-cache, а хешированные сборки — с immutable:

curl -sI https://shifter.ink/ | grep -i cache-control        # no-cache
curl -sI https://shifter.ink/sw.js | grep -i cache-control   # no-cache

Если у вас в браузере остался старый worker, он про эти заголовки не знает — один раз нужно обновиться жёстко (Cmd/Ctrl+Shift+R) или закрыть вкладку и открыть заново. Дальше обновления приезжают сами.

Домен и HTTPS

Всё решает одна строка в /opt/shifter/.env. Сейчас там имена:

SITE_ADDRESS=shifter.ink, www.shifter.ink

Смена адреса — правка строки и перезапуск одного контейнера:

# A-запись домена должна УЖЕ указывать на сервер: сертификат выдаётся по
# проверке на порт 80, и до неё домен обязан резолвиться в него.
sed -i 's/^SITE_ADDRESS=.*/SITE_ADDRESS=example.com, www.example.com/' /opt/shifter/.env
docker compose -f compose.prod.yaml --env-file .env --env-file .env.release up -d proxy

Дальше Caddy сам получит сертификаты Let's Encrypt на каждое имя, сам будет их продлевать и сам начнёт уводить http на https (308). Ни certbot, ни cron, ни перезагрузки конфига — это и есть причина, по которой здесь он, а не nginx.

По IP сайт после этого не открывается, и это правильно: Caddy обслуживает только перечисленные имена. Запрос на http://45.76.251.81 не совпадает ни с одним и остаётся без ответа.

DNS-кеш переживает смену записи: публичные резолверы могут отдавать старый адрес до конца TTL, поэтому «у меня не открывается, а у тебя открывается» сразу после переключения — это почти всегда кеш, а не сервер. Проверять так:

dig +short A shifter.ink @8.8.8.8
curl -sI --resolve shifter.ink:443:45.76.251.81 https://shifter.ink/   # в обход кеша

Приложение при этом остаётся на чистом HTTP внутри сети и не редиректит само: за прокси такой редирект либо уводит на порт, где никто не слушает, либо гоняет браузер по кругу. Вместо этого оно читает X-Forwarded-For и X-Forwarded-Proto (AddHardening в src/Api/Extensions/HardeningExtensions.cs) — иначе все клиенты выглядели бы для лимитера одним адресом прокси и делили бы одно ведро запросов.

Вход через Google

Серверная часть настройки не требует: client id лежит в appsettings.json, он публичный по замыслу, и /shifter/v1/auth/google/config отдаёт его клиенту, чтобы тот решил, рисовать кнопку или нет. Настраивается только сторона Google — там проверяется origin страницы, а не сервер:

  1. Нужен домен и HTTPS. Google Identity Services принимает http:// только для localhost, а IP-адрес в качестве origin не принимает вообще — по голому IP кнопка не заработает никогда, сколько её ни настраивай.
  2. Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client ID типа Web application → Authorized JavaScript origins → добавить https://shifter.ink и https://www.shifter.ink. Origin проверяется целиком, так что имя с www и без — для Google два разных адреса, и нужны оба. Для локальной разработки там же держатся http://localhost:4200 и http://localhost:8080.
  3. Authorized redirect URIs заполнять не нужно. Этот вход проверяет ID-токен, а не обменивает код, так что redirect-адреса в нём не участвуют — как и client secret, который здесь не используется совсем.
  4. OAuth consent screen перевести из Testing в Production. В режиме Testing войти могут только вручную добавленные тестовые аккаунты. Проверки Google при этом не потребуется: приложение просит только openid, email и profile.

Изменения в Console подхватываются не мгновенно — если кнопка появилась, а вход отдаёт ошибку origin, стоит подождать и обновить страницу без кеша.

Как понять, что сломалось:

Что видно Где искать
кнопки нет вовсе /shifter/v1/auth/google/config вернул пустой client_id — значит, Google__ClientId пришёл в контейнер пустым и перекрыл appsettings.json
кнопка есть, в консоли браузера ошибка про origin origin не добавлен в Authorized JavaScript origins либо страница открыта по http
403 Google sign-in is not configured сервер видит пустой client id — то же, что и в первой строке
401 Google sign-in could not be verified токен выписан для другого client id: в Console и в appsettings.json разные приложения, либо токен с телефона, а Google__IosClientId / Google__AndroidClientId на сервере не заданы

Офлайн и уведомления

Приложение ставится на телефон как обычное: манифест, иконки, ярлыки на календарь, статистику и итоги года.

Офлайн-первый service worker прежнего клиента ушёл вместе с ним: он кешировал всю оболочку и после смены фронтенда вечно отдавал бы старую. По его адресу теперь лежит sw.js-киллер — прежний воркер сам находит его при проверке обновлений, тот сносит кеши и разрегистрируется. Новых воркеров приложение не ставит.

Несохранённые дни не теряются. Если запись не доехала, день ложится в IndexedDB и уходит сам, когда связь вернётся. Ключ — дата, поэтому вторая правка того же дня заменяет первую, а не встаёт за ней в очередь. В шапке при этом висит счётчик с кнопкой «отправить».

Важная деталь: «нет сети» и «сервер отказал» обрабатываются по-разному. Первое кладёт день в очередь и оставляет на экране то, что человек ввёл; второе откатывает изменение и показывает ошибку.

Как это считает деньги

Короткий свод правил, чтобы цифры не выглядели магией.

  • Ставка снимается слепком. Когда смена ставится на день, её условия копируются в запись дня. Правка шаблона не переписывает прошлое.
  • Переработка считается по неделям ISO и отдельно по каждому месту: 30 часов в баре и 30 в кафе — это не переработка, потому что ни один работодатель не увидел больше сорока. Доплачивается только часть сверх порога и только для почасовых ставок — у оклада нет часовой базы, чтобы её умножать.
  • Недельный и месячный оклад начисляется один раз за период, сколько бы смен в него ни попало.
  • Tip-out и питание — правила места. Питание удерживается раз в день на место, а не за смену: два выхода в один ресторан — это одна кормёжка.
  • День, поделённый между двумя местами, делит чаевые и продажи пропорционально отработанным часам.
  • Планируемое и заработанное не смешиваются. Месяц будущих смен не показывается как уже полученные деньги. Смена, поставленная на сегодня, считается ещё предстоящей: заработать она пока ничего не успела.
  • Налог берётся с того, что место декларирует. Чаевые входят в базу или нет — по правилу места; tip-out не облагается никогда, эти деньги ушли раньше, чем стали доходом.
  • Отпускные начисляются от оклада и показываются отдельно: это долг на потом, и прибавить их к заработанному значило бы посчитать дважды.
  • Разные валюты не складываются. Если в периоде их больше одной, приложение говорит об этом прямо и показывает цифры по местам.

Все расчёты живут на сервере. Клиент только показывает то, что ему прислали, — дублировать правила на двух сторонах значит однажды разойтись в цифрах.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages