Учёт смен для тех, кому платят по часам: календарь, куда смены накидываются как кисточкой, подсчёт заработка с переработками, чаевыми и удержаниями, и статистика, которая показывает, сколько на самом деле стоит один рабочий день.
ASP.NET Core (.NET 10) + React 19 / Next.js 15 / Tailwind 4, PostgreSQL, всё поднимается одной командой.
- Быстрый старт
- Структура репозитория
- Разработка без Docker
- Переменные окружения
- Базы данных и миграции
- API
- Тесты
- Деплой
- Офлайн и уведомления
- Как это считает деньги
Нужен только Docker.
cp .env.example .env
# впиши ключ подписи: openssl rand -base64 48
docker compose up --buildЧто произойдёт по шагам:
- поднимется PostgreSQL, создадутся базы
shifterиtokens; - compose дождётся, пока база реально начнёт отвечать (healthcheck, а не «sleep 5»);
- отдельный контейнер накатит миграции на обе базы и завершится;
- соберётся фронтенд, соберётся 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 compose up -d db # только PostgreSQL
cd server && dotnet run # API на http://localhost:5208
cd web && npm run dev # SPA на http://localhost:4200Dev-сервер 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 на каждый запрос, который её трогает, и расположением шагов деплоя это не лечится.
Лечится тремя выпусками вместо одного:
- Добавить новую колонку, писать в обе. Ничего не удаляется.
- Выпустить код, который читает новую.
- Убрать старую — когда не осталось ничего, что её читает.
MigrationSafetyTests следит за RenameColumn, RenameTable, DropColumn и
DropTable в Up. Семь уже выпущенных перечислены поимённо: миграцию, которая
где-то отработала, править нельзя, а притворяться, что прошлое было чистым, —
не стоит. Каждая из них клала сайт на длину деплоя, чего никто не заметил,
потому что никто не смотрел.
Базовый путь /shifter/v1. Всё, кроме помеченного «аноним», требует заголовок
Authorization: Bearer <access_token>.
userId всегда берётся из claims токена и никогда из тела запроса.
Swagger в режиме разработки: http://localhost:5208/swagger
| Метод | Путь | Аноним | Что делает |
|---|---|---|---|
| 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":"..."}'| Метод | Путь | Что делает |
|---|---|---|
| GET | / |
профиль: логин, имя, есть ли пароль, привязан ли Google |
| PUT | / |
сменить имя и фамилию |
| PUT | /password |
сменить пароль; заодно гасит все остальные сессии |
| DELETE | /google |
отвязать Google (нельзя, пока нет пароля) |
| DELETE | / |
удалить аккаунт со всеми данными |
Удаление требует ввести свой логин дословно, а при наличии пароля — ещё и пароль.
| Метод | Путь | Что делает |
|---|---|---|
| GET | /locations?archived=true |
список мест |
| POST | /locations |
создать |
| PUT | /locations/{id} |
изменить |
| POST | /locations/{id}/archived |
архивировать или вернуть |
| DELETE | /locations/{id} |
удалить; откажет с 409, если на месте висят смены |
У места живут все правила расчёта: расчётный период, порог переработки и коэффициент, процент tip-out от чая и от продаж, стоимость питания, ставка налога и признак «чаевые тоже облагаются», процент отпускных и валюта.
| Метод | Путь | Что делает |
|---|---|---|
| GET | /shifts?archived=true |
палитра шаблонов |
| POST | /shifts |
создать шаблон |
| PUT | /shifts/{id} |
изменить |
| POST | /shifts/{id}/archived |
архивировать или вернуть |
Шаблон несёт время начала и конца, неоплачиваемый перерыв, ставку и период
оплаты (hour, day, week, month).
| Метод | Путь | Что делает |
|---|---|---|
| GET | /sales?archived=true |
список позиций |
| POST | /sales |
создать |
| PUT | /sales/{id} |
изменить цену или процент |
| POST | /sales/{id}/archived |
архивировать или вернуть |
| DELETE | /sales/{id} |
удалить; откажет с 409, если позиция уже в записанных днях |
| Метод | Путь | Что делает |
|---|---|---|
| GET | /days?from=2026-03-01&to=2026-03-31 |
дни и все итоги за период |
| PUT | /days/{date} |
сохранить день целиком |
| POST | /days/bulk |
накинуть или снять шаблон сразу на много дат |
День сохраняется целиком, а не патчами: клиент присылает полный состав смен, продаж, чаевых и удержаний.
Ответ GET /days
| Метод | Путь | Что делает |
|---|---|---|
| GET | /payouts?from=&to= |
полученные выплаты за период |
| POST | /payouts |
записать выплату; location_id говорит, от кого она |
| DELETE | /payouts/{id} |
удалить |
| GET | /payouts/schedule?from=&to= |
календарь выплат и детектор недоплаты |
schedule разворачивает расчётные периоды каждого места, считает, сколько
должно прийти на руки, сверяет с записанными выплатами и помечает статусом:
open — период ещё идёт, due — ждём, overdue — срок прошёл, paid —
сошлось, short — недоплата, over — переплата. Отдельно возвращает
shortfalls: места, недоплатившие два периода подряд и больше. Один короткий
период — спор о округлении, три подряд — повод идти к тому, кто считает
зарплату.
Нужны для сверки: приложение считает, сколько должны, и сравнивает с тем, сколько пришло.
Чтобы день заполняла не рука, а касса, выгрузка графика или сценарий в 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/.envssh -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 shifterdocker compose down -v стирает том с базой. На проде этой команде делать нечего.
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.iodocker/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) или закрыть вкладку и открыть заново. Дальше обновления приезжают сами.
Всё решает одна строка в /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) — иначе все клиенты
выглядели бы для лимитера одним адресом прокси и делили бы одно ведро запросов.
Серверная часть настройки не требует: client id лежит в appsettings.json, он
публичный по замыслу, и /shifter/v1/auth/google/config отдаёт его клиенту, чтобы
тот решил, рисовать кнопку или нет. Настраивается только сторона Google — там
проверяется origin страницы, а не сервер:
- Нужен домен и HTTPS. Google Identity Services принимает
http://только дляlocalhost, а IP-адрес в качестве origin не принимает вообще — по голому IP кнопка не заработает никогда, сколько её ни настраивай. - 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. - Authorized redirect URIs заполнять не нужно. Этот вход проверяет ID-токен, а не обменивает код, так что redirect-адреса в нём не участвуют — как и client secret, который здесь не используется совсем.
- 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 не облагается никогда, эти деньги ушли раньше, чем стали доходом.
- Отпускные начисляются от оклада и показываются отдельно: это долг на потом, и прибавить их к заработанному значило бы посчитать дважды.
- Разные валюты не складываются. Если в периоде их больше одной, приложение говорит об этом прямо и показывает цифры по местам.
Все расчёты живут на сервере. Клиент только показывает то, что ему прислали, — дублировать правила на двух сторонах значит однажды разойтись в цифрах.
{ "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": [ /* разбивка по местам */ ] }