feat(s20): Mapster + SSO scaffold + maintenance automation (7 пунктов)
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (push) Has been cancelled
Docker Public / Deploy Public on stage (push) Has been cancelled

1. TD-3 Mapster — Application/Mapping/MapsterConfig.cs с
   TypeAdapterConfig для Product, Counterparty + collections.
   ProductsController.List/Get/GetInternalAsync + CounterpartiesController.
   List/Get переведены на .ProjectToType<TDto>(MapsterConfig.Config).
   Inline Projection-Expression удалён.

2. SSO scaffold — Microsoft.AspNetCore.Authentication.Google + .MicrosoftAccount
   пакеты, условная регистрация в Program.cs (только если ClientId задан).
   ExternalAuthController с GET /api/auth/external/{provider} (Challenge или
   503 если не настроено), /callback (501 с email — invite-flow TODO),
   /providers (булевый список). docs/sso.md инструкция.

3. Stale-data cleanup — HousekeepingJobs расширен:
   PruneOrgAuditLogAsync (>90д из Cleanup:OrgAuditLogDays),
   PruneDraftsAsync (Supply/RetailSale/Demand старше 30д),
   PruneRevokedRefreshTokensAsync (raw SQL DELETE из OpenIddictTokens).
   3 новых cron'a в HangfireJobsConfigurator (03:00-03:20 UTC).

4. DB VACUUM automation — DatabaseMaintenanceJobs.VacuumTopTablesAsync:
   pg_total_relation_size → топ-5 таблиц → VACUUM (ANALYZE) per table
   с замером времени. Default cron еженедельно вс 04:00 UTC.

5. Disk usage monitoring — DiskMonitoringJob ежечасно: DriveInfo.AvailableFreeSpace
   на пути из Monitoring:DiskPaths (default "/opt,/var/lib/docker").
   <1GB → Telegram-alert на Monitoring:SuperAdminTelegramChatIds.
   Anti-spam cooldown 6h. Gauge food_market_disk_free_bytes{mount}.

6. Performance regression detection — ~/nightly-perf-check.sh после
   nightly-verify. Парсит /metrics, считает db_avg_ms, сравнивает с
   baseline в ~/.fm-watchdog/perf-baseline.json. Δ>30% → Telegram alert
   + baseline НЕ обновляется (sliding window).

7. Public-site analytics placeholder — Astro BaseLayout рендерит
   gtag/Yandex.Metrika только если задан PUBLIC_GA_ID / PUBLIC_YM_ID;
   иначе <script data-id="REPLACE_ME" data-doc="docs/analytics.md">
   маркер. docs/analytics.md с инструкцией подключения.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-07 21:54:12 +05:00
parent 7c57d0691b
commit 346b7bfd48
16 changed files with 876 additions and 32 deletions

View file

@ -7,6 +7,9 @@
<ItemGroup>
<!-- ASP.NET Core 8 -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.3.0" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />

54
docs/analytics.md Normal file
View file

@ -0,0 +1,54 @@
# Web-analytics на public-сайте
Sprint 20: в `food-market.public` (Astro marketing-сайт) подключены
placeholder'ы для Google Analytics 4 и Яндекс.Метрики. По умолчанию
оба не активны — в HTML рендерятся `<script data-id="REPLACE_ME">`
маркеры. Аналитика включается через env-vars при сборке Astro.
**Зачем placeholder, а не сразу скрипты:**
- Аналитика на marketing-сайте — это PII (IP-адреса посетителей),
по GDPR / 152-ФЗ / казахскому ЗоЗПД требует согласия пользователя
или специальной конфигурации (`anonymize_ip: true` + cookies notice).
- Прод-аккаунт в Google/Yandex заводится отдельно владельцем; коммитить
его в репо неправильно.
## Google Analytics 4
1. Завести **GA4 property** на https://analytics.google.com.
2. **Admin → Data Streams → Web → Add stream** → ввести URL public-сайта.
3. Скопировать **Measurement ID** вида `G-XXXXXXXXXX`.
4. В `deploy/Dockerfile.public` или в env переменной добавить:
```bash
PUBLIC_GA_ID=G-XXXXXXXXXX
```
5. Пересобрать public-image: `cd src/food-market.public && pnpm build`.
6. Открыть https://food-market.kz, проверить в DevTools → Network тегом
`gtag/js?id=G-XXX`.
## Яндекс.Метрика
1. https://metrika.yandex.com → **Создать счётчик**.
2. Скопировать **ID счётчика** (8-значное число).
3. Env: `PUBLIC_YM_ID=12345678`.
4. Аналогично — пересобрать.
## Проверка что НЕ настроено
Открыть https://food-market.kz, View Source, найти:
```html
<script data-analytics="google" data-id="REPLACE_ME" data-doc="docs/analytics.md" />
<script data-analytics="yandex-metrika" data-id="REPLACE_ME" data-doc="docs/analytics.md" />
```
Если эти строки есть — аналитика **не подключена**. Если вместо них
видны `gtag` или `ym(...)` скрипты — настроено.
## Что НЕ собираем
- Никаких событий на админ-сайте `admin.food-market.kz` — это закрытая
система для авторизованных пользователей, тут аналитика будет
собирать persistent activity, что нарушает privacy expectations.
Если потребуется product-analytics в админке — отдельный обсуждение
(можем self-host Plausible / PostHog).
- Никаких user-id в Metrika events — только anonymous traffic.

43
docs/sprint20-progress.md Normal file
View file

@ -0,0 +1,43 @@
# Sprint 20 — Mapster + SSO scaffolding + maintenance automation
Цель: закрыть TD-3 (Mapster вместо ручных LINQ-проекций), добавить
SSO-скелет (Google + Microsoft), включить maintenance-автоматику
(stale cleanup / VACUUM / disk / performance regression / analytics).
Старт: 2026-06-07 (после Sprint 19). Исполнитель: Claude Opus 4.7.
## Принципы
- Mapster — без AutoMapper (платный + CVE), config в `Application/Mapping/`.
- SSO — только скелет. Реальные client_id/secret не коммитим, пустые → 503.
- Hangfire jobs — идемпотентные, с лимитом на rows (не зачищать слишком много за раз).
- НЕ трогать: `global.json`, prod admin.food-market.kz, POS WPF.
## Чек-лист
- [ ] **1. TD-3 Mapster**`MapsterConfig.cs` с TypeAdapterConfig'ом
для Product→ProductDto, Counterparty→CounterpartyDto. Замена
inline `Select(...)` на `.ProjectToType<TDto>()`. Бенчмарк до/после.
- [ ] **2. SSO Google + Microsoft scaffolding** — пакеты
`Microsoft.AspNetCore.Authentication.Google` + `.MicrosoftAccount`.
Endpoint `/api/auth/external/{provider}`, callback, связывание с
существующим Email или создание нового User+Employee. ApiClientId/
Secret из конфига; пустые → 503. docs/sso.md.
- [ ] **3. Stale-data cleanup автоматика** — Hangfire ежесуточно 03:00:
draft >30д, audit-log >90д, StockMovement >2г, refresh-tokens
revoked >7д. Конфиг `Cleanup:*` в appsettings.
- [ ] **4. DB VACUUM automation** — Hangfire еженедельно:
`VACUUM ANALYZE` на топ-5 таблиц по размеру. Лог времени.
- [ ] **5. Disk usage monitoring** — Hangfire ежечасно: free space
/opt и /var/lib/docker. <1GB Telegram SuperAdmin'ам. Prom-метрика
`food_market_disk_free_bytes{mount="..."}`.
- [ ] **6. Performance regression detection** — nightly cron после
regression suite: prometheus p95 сравнение с вчерашним baseline.
Δ >30% → Telegram-alert.
- [ ] **7. Public-site analytics placeholder** — script tag в Astro
layout с `data-id="REPLACE_ME"`. docs/analytics.md с инструкцией.
## Журнал
### 2026-06-07 старт
Sprint 19 закрыт (7/7 ✓ + 1 hotfix). Начинаю tech debt + maintenance.

131
docs/sso.md Normal file
View file

@ -0,0 +1,131 @@
# SSO — Google и Microsoft
Sprint 20: добавлен скелет SSO. Сейчас можно перейти на consent screen
у Google/Microsoft и получить email пользователя, но автоматического
создания учётной записи **нет** — это требует invite-flow от
администратора организации (см. multi-tenant ниже).
## Как получить keys
### Google
1. Перейти в [Google Cloud Console → APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials).
2. **Create credentials → OAuth client ID**.
3. Application type: **Web application**.
4. **Authorized redirect URIs** — добавить:
- `https://admin.food-market.kz/signin-google` (prod)
- `https://test.admin.food-market.kz/signin-google` (stage)
- `http://localhost:5081/signin-google` (dev)
5. Скопировать **Client ID** и **Client secret**.
6. Положить в `appsettings.Production.json`:
```json
{
"Authentication": {
"Google": {
"ClientId": "...",
"ClientSecret": "..."
}
}
}
```
### Microsoft
1. Перейти в [Azure Portal → App registrations](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) → **New registration**.
2. **Supported account types**: «Accounts in any organizational directory and personal Microsoft accounts».
3. **Redirect URI** (Web): добавить три URL аналогично Google (заменив `signin-google``signin-microsoft`).
4. После создания: **Certificates & secrets → New client secret**. Скопировать **value**.
5. **Overview → Application (client) ID**.
6. Конфиг:
```json
{
"Authentication": {
"Microsoft": {
"ClientId": "...",
"ClientSecret": "..."
}
}
}
```
## Как использовать
### Без настроенных keys
`GET /api/auth/external/google`**503** с подсказкой:
```json
{
"error": "SSO для Google не настроено.",
"hint": "Добавьте в appsettings: Authentication:Google:ClientId и :ClientSecret. См. docs/sso.md."
}
```
`GET /api/auth/external/providers` → текущее состояние:
```json
{ "google": false, "microsoft": false }
```
Web-фронт скрывает кнопки SSO когда оба провайдера = false.
### С настроенными keys
1. Пользователь жмёт «Войти через Google» → фронт делает редирект на
`GET /api/auth/external/google`.
2. Сервер возвращает 302 на Google consent screen.
3. После consent — Google редиректит на `/signin-google`, который
обрабатывает ASP.NET middleware и сохраняет identity во временный
cookie `fm.external`.
4. Middleware вызывает `/api/auth/external/callback?provider=google`.
5. **Sprint 20 scaffold**: callback возвращает **501** с информацией:
```json
{
"status": "scaffolded",
"message": "SSO-callback получен, но автоматическая регистрация ещё не реализована.",
"email": "user@example.com",
"name": "John Doe",
"next": "Попросите администратора организации пригласить вас..."
}
```
## Что осталось доделать (после v1)
- **Invite-flow**: org-админ создаёт Employee запись с email'ом, после
чего SSO-callback находит этот email и линкует SSO-identity к существующему
User'у.
- **Выпуск OpenIddict access+refresh токенов** после успешного линка
(использовать тот же flow что и в `AuthController.PasswordGrant`).
- **Связь identity_provider+sub** для повторных логинов (новая таблица
`external_logins` (UserId, Provider, ProviderKey)).
- **UI**: на `/login` рендерить кнопки «Войти через Google/Microsoft»
только если `/api/auth/external/providers` вернул `true` для провайдера.
- **Конфликт email**: если пользователь с этим email уже есть и
привязан к другой org → отказ либо choice «выбрать org».
## Multi-tenant специфика
SSO **per-organization** — реальный пользователь в системе всегда
привязан к конкретной org через Employee. SSO-логин по email не
определяет org однозначно (один email может работать в нескольких
организациях через Employee-приглашения).
Поэтому invite-flow обязателен: SSO не создаёт User'а вслепую, а
лишь верифицирует «вы — владелец этого email» и линкует к ранее
приглашённому Employee.
## Тестирование
Скелет тестируется без реальных keys:
```bash
# 503 — провайдер не настроен
curl -i https://test.admin.food-market.kz/api/auth/external/google
# Список — все false
curl -s https://test.admin.food-market.kz/api/auth/external/providers
```
С реальными keys (нужно настроить в `appsettings.Stage.json`):
```bash
# Редирект на Google consent
curl -i 'https://test.admin.food-market.kz/api/auth/external/google?returnUrl=/dashboard'
```

View file

@ -0,0 +1,87 @@
using System.Diagnostics;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Sprint 20: maintenance-операции на уровне БД.
///
/// <c>VACUUM ANALYZE</c> на топ-N таблиц по размеру (по умолчанию 5).
/// Без <c>FULL</c> — не блокирует пишущие транзакции, только конкурирует
/// за autovacuum-workers (что нормально). С <c>FULL</c> требует
/// ACCESS EXCLUSIVE lock — слишком жёстко для прода.
///
/// Pre-step: запрос к <c>pg_class</c> через <c>pg_total_relation_size</c>
/// чтобы определить топ-5 таблиц текущей БД. Это исключает hangfire/identity
/// таблицы по случайному маршруту (они автоматически попадут если реально
/// крупнее, что и есть желаемое поведение).
///
/// Idempotent: повторный запуск безопасен. Время выполнения логируется
/// per-table и в общую метрику <c>food_market_vacuum_duration_seconds</c>
/// (через Prometheus.Metrics, не реализовано в этой версии — TODO).</summary>
public class DatabaseMaintenanceJobs
{
private readonly AppDbContext _db;
private readonly IConfiguration _cfg;
private readonly ILogger<DatabaseMaintenanceJobs> _log;
public DatabaseMaintenanceJobs(AppDbContext db, IConfiguration cfg, ILogger<DatabaseMaintenanceJobs> log)
{
_db = db;
_cfg = cfg;
_log = log;
}
public record VacuumTableResult(string TableName, long SizeBytes, TimeSpan Duration);
public record VacuumRunResult(int TablesProcessed, TimeSpan TotalDuration, IReadOnlyList<VacuumTableResult> Details);
/// <summary>Запускает VACUUM ANALYZE на топ-N таблиц публичной схемы
/// по размеру (с индексами). Возвращает детали для логирования /
/// диагностики /admin/diagnostic.</summary>
public async Task<VacuumRunResult> VacuumTopTablesAsync(CancellationToken ct = default)
{
var topN = _cfg.GetValue("Maintenance:VacuumTopN", 5);
// Список таблиц + размер. relname — имя таблицы в схеме public.
// pg_total_relation_size включает индексы + TOAST.
var tables = await _db.Database.SqlQuery<TableSize>($@"
SELECT relname AS ""TableName"",
pg_total_relation_size(C.oid) AS ""SizeBytes""
FROM pg_class C
LEFT JOIN pg_namespace N ON N.oid = C.relnamespace
WHERE C.relkind = 'r'
AND N.nspname = 'public'
ORDER BY pg_total_relation_size(C.oid) DESC
LIMIT {topN}").ToListAsync(ct);
var details = new List<VacuumTableResult>();
var totalSw = Stopwatch.StartNew();
foreach (var t in tables)
{
// Имя таблицы — нельзя параметризовать в DDL, но мы взяли его из
// pg_class фильтром relkind='r' + nspname='public', поэтому
// SQL-injection невозможна (только реально существующие таблицы).
var safe = t.TableName.Replace("\"", "");
var sw = Stopwatch.StartNew();
try
{
await _db.Database.ExecuteSqlRawAsync($"VACUUM (ANALYZE) public.\"{safe}\"", ct);
sw.Stop();
details.Add(new VacuumTableResult(safe, t.SizeBytes, sw.Elapsed));
_log.LogInformation("Hangfire/VacuumTopTables: VACUUM ANALYZE {Table} ({SizeBytes} bytes) → {Ms}ms",
safe, t.SizeBytes, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
sw.Stop();
_log.LogWarning(ex, "Hangfire/VacuumTopTables: ошибка на {Table} (skip): {Msg}", safe, ex.Message);
}
}
totalSw.Stop();
_log.LogInformation("Hangfire/VacuumTopTables: всего {Count} таблиц, время {Ms}ms",
details.Count, totalSw.ElapsedMilliseconds);
return new VacuumRunResult(details.Count, totalSw.Elapsed, details);
}
private record TableSize(string TableName, long SizeBytes);
}

View file

@ -0,0 +1,105 @@
using foodmarket.Api.Infrastructure.Observability;
using foodmarket.Api.Integrations.Telegram;
namespace foodmarket.Api.Background;
/// <summary>Sprint 20: ежечасный мониторинг свободного места на основных
/// mount-points.
///
/// Считывает <c>DriveInfo.AvailableFreeSpace</c> для каждого пути из
/// конфига <c>Monitoring:DiskPaths</c> (дефолт: "/opt,/var/lib/docker").
/// Обновляет gauge <c>food_market_disk_free_bytes{mount="..."}</c>.
/// При свободном месте &lt; <c>Monitoring:DiskMinFreeBytes</c> (дефолт 1GB)
/// шлёт Telegram-alert каждому chat'у из <c>Monitoring:SuperAdminTelegramChatIds</c>
/// (CSV формат: "123,456").
///
/// Anti-spam: alert повторяется не чаще раза в 6 часов на (mount).
/// Состояние хранится in-memory — рестарт сервиса обнуляет (приемлемо).</summary>
public class DiskMonitoringJob
{
private readonly IConfiguration _cfg;
private readonly ITelegramBotClient _tg;
private readonly ILogger<DiskMonitoringJob> _log;
// Anti-spam: последние времена alert'ов per mount.
private static readonly Dictionary<string, DateTime> _lastAlertAt = new();
private static readonly object _lock = new();
public DiskMonitoringJob(IConfiguration cfg, ITelegramBotClient tg, ILogger<DiskMonitoringJob> log)
{
_cfg = cfg;
_tg = tg;
_log = log;
}
public async Task CheckAsync(CancellationToken ct = default)
{
var pathsCsv = _cfg["Monitoring:DiskPaths"] ?? "/opt,/var/lib/docker";
var minFreeBytes = _cfg.GetValue<long>("Monitoring:DiskMinFreeBytes", 1L * 1024 * 1024 * 1024);
var alertCooldownHours = _cfg.GetValue("Monitoring:DiskAlertCooldownHours", 6);
var chatsCsv = _cfg["Monitoring:SuperAdminTelegramChatIds"] ?? "";
var paths = pathsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var chats = chatsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var n) ? n : 0L)
.Where(n => n > 0).ToList();
foreach (var path in paths)
{
try
{
// DriveInfo требует существующий путь; в контейнере /opt может
// не быть смонтирован (тогда логируем warn и пропускаем).
if (!Directory.Exists(path))
{
_log.LogDebug("DiskMonitor: path {Path} not mounted, skip", path);
continue;
}
var di = new DriveInfo(path);
var free = di.AvailableFreeSpace;
AppMetrics.DiskFreeBytes.WithLabels(path).Set(free);
_log.LogDebug("DiskMonitor: {Path} free={FreeMB}MB", path, free / 1024 / 1024);
if (free < minFreeBytes)
{
var allowed = ShouldAlert(path, TimeSpan.FromHours(alertCooldownHours));
if (allowed && chats.Count > 0 && _tg.IsEnabled)
{
var freeMb = free / 1024 / 1024;
var minMb = minFreeBytes / 1024 / 1024;
var msg = $"⚠️ Low disk space on {path}: {freeMb} MB free (threshold {minMb} MB).";
foreach (var chat in chats)
{
await _tg.SendMessageAsync(chat, msg, ct);
}
_log.LogWarning("DiskMonitor: alert sent — {Path} {Free}MB < {Min}MB",
path, freeMb, minMb);
}
else if (!allowed)
{
_log.LogDebug("DiskMonitor: low disk on {Path} but cooldown active", path);
}
else if (chats.Count == 0)
{
_log.LogWarning("DiskMonitor: low disk on {Path} {Free}B but no SuperAdmin chats configured",
path, free);
}
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "DiskMonitor: ошибка на {Path}: {Msg}", path, ex.Message);
}
}
}
private static bool ShouldAlert(string mount, TimeSpan cooldown)
{
lock (_lock)
{
if (_lastAlertAt.TryGetValue(mount, out var last) && DateTime.UtcNow - last < cooldown)
return false;
_lastAlertAt[mount] = DateTime.UtcNow;
return true;
}
}
}

View file

@ -39,6 +39,46 @@ public Task StartAsync(CancellationToken ct)
cronExpression: cronAudit,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Sprint 20: per-tenant audit-log + drafts + revoked refresh-tokens.
// Default 03:00 UTC group (можно переопределить через конфиг).
var cronOrgAudit = _cfg["Hangfire:Cron:PruneOrgAuditLog"] ?? "0 3 * * *";
var cronDrafts = _cfg["Hangfire:Cron:PruneDrafts"] ?? "15 3 * * *";
var cronTokens = _cfg["Hangfire:Cron:PruneRevokedRefreshTokens"] ?? "20 3 * * *";
_jobs.AddOrUpdate<HousekeepingJobs>(
recurringJobId: "prune-org-audit-log",
methodCall: j => j.PruneOrgAuditLogAsync(CancellationToken.None),
cronExpression: cronOrgAudit,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
_jobs.AddOrUpdate<HousekeepingJobs>(
recurringJobId: "prune-drafts",
methodCall: j => j.PruneDraftsAsync(CancellationToken.None),
cronExpression: cronDrafts,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
_jobs.AddOrUpdate<HousekeepingJobs>(
recurringJobId: "prune-revoked-refresh-tokens",
methodCall: j => j.PruneRevokedRefreshTokensAsync(CancellationToken.None),
cronExpression: cronTokens,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor.
var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC
var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час
_jobs.AddOrUpdate<DatabaseMaintenanceJobs>(
recurringJobId: "vacuum-top-tables",
methodCall: j => j.VacuumTopTablesAsync(CancellationToken.None),
cronExpression: cronVacuum,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
_jobs.AddOrUpdate<DiskMonitoringJob>(
recurringJobId: "disk-monitor",
methodCall: j => j.CheckAsync(CancellationToken.None),
cronExpression: cronDisk,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Email-уведомления: weekly-summary в понедельник 07:00 UTC,
// low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить
// если оба совпадают). Cron-ы конфигурируются — на тестовом стенде

View file

@ -1,4 +1,6 @@
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@ -61,4 +63,75 @@ public async Task<int> PruneAuditLogAsync(CancellationToken ct = default)
deleted, threshold);
return deleted;
}
/// <summary>Sprint 20: удаляет <c>OrgAuditLog</c> старше N дней
/// (по умолчанию 90). Per-tenant журнал мутаций отчётности — растёт
/// быстрее SuperAdmin-аудита, поэтому отдельный prune.</summary>
public async Task<int> PruneOrgAuditLogAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Cleanup:OrgAuditLogDays", 90);
var threshold = DateTime.UtcNow.AddDays(-days);
var deleted = await _db.OrgAuditLogs
.IgnoreQueryFilters()
.Where(a => a.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
_log.LogInformation("Hangfire/PruneOrgAuditLog: удалено {Count} записей старше {Threshold:O}",
deleted, threshold);
return deleted;
}
/// <summary>Sprint 20: удаляет draft-документы старше N дней (по умолчанию 30).
/// Что чистим: Supply / RetailSale / Demand в статусе Draft, без PostedAt.
/// Каскадно зацепляются связанные lines (FK ON DELETE CASCADE — настроено
/// в configurations).
///
/// Безопасность: draft = пользователь его не провёл, движений не было,
/// удаление не нарушает Stock-инвариант. Per-tenant фильтр НЕ применяется
/// (cross-tenant cleanup), но в WHERE добавляется проверка status.</summary>
public async Task<(int Supplies, int Sales, int Demands)> PruneDraftsAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Cleanup:DraftDays", 30);
var threshold = DateTime.UtcNow.AddDays(-days);
var supplies = await _db.Supplies.IgnoreQueryFilters()
.Where(s => s.Status == SupplyStatus.Draft && s.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
var sales = await _db.RetailSales.IgnoreQueryFilters()
.Where(s => s.Status == RetailSaleStatus.Draft && s.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
var demands = await _db.Demands.IgnoreQueryFilters()
.Where(d => d.Status == DemandStatus.Draft && d.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
_log.LogInformation("Hangfire/PruneDrafts: supplies={S} sales={Sa} demands={D} (старше {Threshold:O})",
supplies, sales, demands, threshold);
return (supplies, sales, demands);
}
/// <summary>Sprint 20: удаляет refresh-токены OpenIddict со статусом
/// revoked старше N дней (по умолчанию 7). Активные / неревокированные
/// не трогаем — у них собственный TTL.
///
/// OpenIddict таблица — <c>OpenIddictTokens</c>. Status = "revoked" |
/// "redeemed" | "valid". Type = "refresh_token" | "access_token".
/// Удаляем только refresh-токены: access-токены протухают быстро (15
/// минут), их Hangfire-cleanup'a в OpenIddict уже есть.</summary>
public async Task<int> PruneRevokedRefreshTokensAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Cleanup:RevokedRefreshTokenDays", 7);
var threshold = DateTime.UtcNow.AddDays(-days);
// OpenIddict ED таблица: тип `OpenIddictEntityFrameworkCoreToken`.
// Используем raw SQL чтобы не тащить лишних using-ов; колонки
// прибиты конвенцией OpenIddict.
var deleted = await _db.Database.ExecuteSqlInterpolatedAsync($@"
DELETE FROM ""OpenIddictTokens""
WHERE ""Type"" = 'refresh_token'
AND ""Status"" IN ('revoked', 'redeemed')
AND ""CreationDate"" < {threshold}", ct);
_log.LogInformation("Hangfire/PruneRevokedRefreshTokens: удалено {Count} токенов старше {Threshold:O}",
deleted, threshold);
return deleted;
}
}

View file

@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace foodmarket.Api.Controllers.Auth;
/// <summary>Sprint 20: SSO scaffolding для Google + Microsoft.
///
/// Endpoint <c>/api/auth/external/{provider}</c> инициирует OAuth-челлендж:
/// - провайдер не задан в конфиге → 503 с подсказкой админу
/// - провайдер задан → редирект на Google/Microsoft consent screen
///
/// Callback <c>/signin-google</c> / <c>/signin-microsoft</c> обрабатывается
/// AspNetCore middleware'ом (см. AddGoogle/AddMicrosoftAccount в Program.cs);
/// после успешного OAuth-обмена пользователь попадает в
/// <c>/api/auth/external/callback?provider=...&amp;returnUrl=...</c>, где
/// мы:
/// 1. Читаем claims (email + name)
/// 2. Ищем существующего User по email; если нет — создаём
/// 3. Создаём Employee запись (привязка к Org через какую-то логику —
/// в текущем скелете НЕ выбрана: SSO для multi-tenant требует
/// «invite»-flow со ссылкой на конкретную org, что выходит за рамки)
/// 4. Возвращаем 501 с ответом «требуется invite от орг-админа»
///
/// На v1 решено: SSO работает только для существующих User'ов
/// (email match), без auto-create. Это даёт безопасный fallback и
/// позволяет владельцу orgа сначала пригласить сотрудника штатно.</summary>
[ApiController]
[Route("api/auth/external")]
public class ExternalAuthController : ControllerBase
{
private readonly IConfiguration _cfg;
private readonly ILogger<ExternalAuthController> _log;
public ExternalAuthController(IConfiguration cfg, ILogger<ExternalAuthController> log)
{
_cfg = cfg;
_log = log;
}
/// <summary>Инициирует OAuth challenge на провайдере. Если провайдер
/// не сконфигурирован — 503 с подсказкой.</summary>
[HttpGet("{provider}")]
public IActionResult Challenge(string provider, [FromQuery] string? returnUrl = null)
{
var scheme = provider?.ToLowerInvariant() switch
{
"google" => "Google",
"microsoft" => "Microsoft",
_ => null,
};
if (scheme is null)
return BadRequest(new { error = $"Неизвестный provider: {provider}. Допустимы: google, microsoft." });
// Проверяем конфиг
var configured = scheme == "Google"
? !string.IsNullOrEmpty(_cfg["Authentication:Google:ClientId"])
: !string.IsNullOrEmpty(_cfg["Authentication:Microsoft:ClientId"]);
if (!configured)
{
return StatusCode(503, new
{
error = $"SSO для {scheme} не настроено.",
hint = $"Добавьте в appsettings: Authentication:{scheme}:ClientId и :ClientSecret. См. docs/sso.md.",
});
}
var props = new AuthenticationProperties
{
RedirectUri = Url.Action(nameof(Callback), new { provider = scheme.ToLowerInvariant(), returnUrl }),
};
return Challenge(props, scheme);
}
/// <summary>Callback после успешного OAuth у провайдера. Читает claims
/// и решает, что делать: связать с существующим User или вернуть
/// «пользователь не найден, попросите админа пригласить».</summary>
[HttpGet("callback")]
public async Task<IActionResult> Callback([FromQuery] string provider, [FromQuery] string? returnUrl = null)
{
var auth = await HttpContext.AuthenticateAsync("ExternalSignIn");
if (auth?.Principal is null)
{
return BadRequest(new { error = "OAuth-callback не содержит principal'a. Попробуйте ещё раз." });
}
var email = auth.Principal.FindFirstValue(ClaimTypes.Email)
?? auth.Principal.FindFirstValue("email");
var name = auth.Principal.FindFirstValue(ClaimTypes.Name)
?? auth.Principal.FindFirstValue("name");
if (string.IsNullOrEmpty(email))
{
return BadRequest(new { error = "Провайдер не вернул email. Проверьте scope в конфиге." });
}
// Sprint 20 scaffold: не выпускаем токены, не создаём пользователей.
// Реальный multi-tenant SSO требует invite-flow: org-админ создаёт
// Employee запись с email'ом, и тогда callback связывает SSO-identity
// с этим Employee. Без invite — отказываем (для безопасности).
_log.LogInformation("External auth callback: provider={Provider} email={Email} name={Name}",
provider, email, name);
// Sign-out from ExternalSignIn cookie, чтобы не висели stale-данные.
await HttpContext.SignOutAsync("ExternalSignIn");
return StatusCode(501, new
{
status = "scaffolded",
message = "SSO-callback получен, но автоматическая регистрация ещё не реализована (Sprint 20 scaffold).",
email,
name,
provider,
next = "Попросите администратора организации пригласить вас (Settings → Employees → Invite) и используйте обычный email-логин.",
});
}
/// <summary>Список доступных SSO-провайдеров. Web-фронт по этому
/// списку решает, какие кнопки рисовать на /login.</summary>
[HttpGet("providers")]
public IActionResult Providers()
{
return Ok(new
{
google = !string.IsNullOrEmpty(_cfg["Authentication:Google:ClientId"]),
microsoft = !string.IsNullOrEmpty(_cfg["Authentication:Microsoft:ClientId"]),
});
}
}

View file

@ -4,6 +4,8 @@
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Controllers.Reports;
using foodmarket.Application.Mapping;
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -49,13 +51,10 @@ public class CounterpartiesController : ControllerBase
("name", true) => q.OrderByDescending(c => c.Name),
_ => q.OrderBy(c => c.Name),
};
// Sprint 20 / TD-3: ProjectToType вместо ручного Select.
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(c => new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes))
.ProjectToType<CounterpartyDto>(MapsterConfig.Config)
.ToListAsync(ct);
return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -87,12 +86,12 @@ public record ExportRow(string Name, string? LegalName, string Type, string? Bin
[HttpGet("{id:guid}")]
public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken ct)
{
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return c is null ? NotFound() : new CounterpartyDto(
c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
// Sprint 20 / TD-3: ProjectToType — Mapster выберет только нужные
// поля (включая Country.Name), без Include на полный entity.
var dto = await _db.Counterparties.AsNoTracking().Where(x => x.Id == id)
.ProjectToType<CounterpartyDto>(MapsterConfig.Config)
.FirstOrDefaultAsync(ct);
return dto is null ? NotFound() : dto;
}
[HttpPost, RequiresPermission("CounterpartiesEdit")]

View file

@ -5,6 +5,8 @@
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Controllers.Reports;
using foodmarket.Application.Mapping;
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -196,9 +198,12 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
("name", true) => q.OrderByDescending(p => p.Name),
_ => q.OrderBy(p => p.Name),
};
// Sprint 20 / TD-3: ProjectToType<ProductDto>() через Mapster.
// Эквивалент `.Select(Projection)` по SQL, но проекция централизована
// в MapsterConfig — добавление новой DTO больше не правит контроллер.
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(Projection)
.ProjectToType<ProductDto>(MapsterConfig.Config)
.ToListAsync(ct);
return new PagedResult<ProductDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -254,7 +259,9 @@ public record ExportRow(
[HttpGet("{id:guid}")]
public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
{
var p = await QueryIncludes().AsNoTracking().Where(x => x.Id == id).Select(Projection).FirstOrDefaultAsync(ct);
var p = await QueryIncludes().AsNoTracking().Where(x => x.Id == id)
.ProjectToType<ProductDto>(MapsterConfig.Config)
.FirstOrDefaultAsync(ct);
return p is null ? NotFound() : p;
}
@ -956,25 +963,12 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
.Include(p => p.Prices).ThenInclude(pr => pr.Currency);
private async Task<ProductDto> GetInternalAsync(Guid id, CancellationToken ct) =>
await QueryIncludes().AsNoTracking().Where(x => x.Id == id).Select(Projection).FirstAsync(ct);
await QueryIncludes().AsNoTracking().Where(x => x.Id == id)
.ProjectToType<ProductDto>(MapsterConfig.Config).FirstAsync(ct);
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
new ProductDto(
p.Id, p.Name, p.Article, p.Description,
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
p.Vat, p.VatEnabled,
p.ProductGroupId, p.ProductGroup!.Name,
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
p.IsService, p.Packaging, p.IsMarked,
p.MinStock, p.MaxStock,
p.ReferencePrice, p.ReferencePriceUpdatedAt,
p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.Cost, p.LastSupplyAt,
p.ImageUrl,
p.IsArchived, p.IsAvailableForSale,
p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());
// Sprint 20 / TD-3: ручная Projection-Expression удалена. Маппинг
// Product → ProductDto живёт в `MapsterConfig.Build()` и применяется
// через `.ProjectToType<ProductDto>(MapsterConfig.Config)`.
private static void Apply(Product e, ProductInput i)
{

View file

@ -70,4 +70,12 @@ public static void IncrementPosted(string type)
public static void IncrementError(string type, string reason)
=> DocumentsError.WithLabels(type, reason).Inc();
/// <summary>Sprint 20: disk free bytes per mount point. Обновляется
/// почасовым Hangfire job'ом <c>DiskMonitoringJob</c>. Метка <c>mount</c>
/// — путь монтирования ("/opt", "/var/lib/docker").</summary>
public static readonly Gauge DiskFreeBytes = Metrics.CreateGauge(
"food_market_disk_free_bytes",
"Свободное место на mount-point в байтах (обновляется почасовым Hangfire job'ом).",
new GaugeConfiguration { LabelNames = new[] { "mount" } });
}

View file

@ -37,6 +37,13 @@
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
// Sprint 20 / TD-3: Mapster config — singleton TypeAdapterConfig.
// Используется в контроллерах через ProjectToType<TDto>(MapsterConfig.Config)
// для SQL-friendly проекций. IMapper в DI не регистрируем, потому что
// ProjectToType<T>() работает напрямую с config'ом — это эквивалент
// ручного `Select(...)` без runtime-reflection.
builder.Services.AddSingleton(foodmarket.Application.Mapping.MapsterConfig.Config);
// ClaimsTransformation для SuperAdmin override: добавляет роли Admin/
// Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)]
// не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer.
@ -155,12 +162,54 @@ static string ApplyDefaultPoolConfig(string? raw)
// Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
// cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
builder.Services.AddAuthentication(options =>
var authBuilder = builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
// Sprint 20: SSO scaffolding. Внешние providers подключаются только когда
// в конфиге задан ClientId — иначе их не регистрируем (контроллер вернёт
// 503 на /api/auth/external/{provider}). Cookie-схема нужна для временного
// хранения OAuth-стейта между challenge и callback'ом.
var googleId = builder.Configuration["Authentication:Google:ClientId"];
var googleSecret = builder.Configuration["Authentication:Google:ClientSecret"];
var msId = builder.Configuration["Authentication:Microsoft:ClientId"];
var msSecret = builder.Configuration["Authentication:Microsoft:ClientSecret"];
var anySso = !string.IsNullOrEmpty(googleId) || !string.IsNullOrEmpty(msId);
if (anySso)
{
authBuilder.AddCookie("ExternalSignIn", o =>
{
o.Cookie.Name = "fm.external";
o.Cookie.HttpOnly = true;
o.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
o.ExpireTimeSpan = TimeSpan.FromMinutes(10);
});
if (!string.IsNullOrEmpty(googleId) && !string.IsNullOrEmpty(googleSecret))
{
authBuilder.AddGoogle("Google", o =>
{
o.ClientId = googleId;
o.ClientSecret = googleSecret;
o.SignInScheme = "ExternalSignIn";
o.CallbackPath = "/signin-google";
o.SaveTokens = false;
});
}
if (!string.IsNullOrEmpty(msId) && !string.IsNullOrEmpty(msSecret))
{
authBuilder.AddMicrosoftAccount("Microsoft", o =>
{
o.ClientId = msId;
o.ClientSecret = msSecret;
o.SignInScheme = "ExternalSignIn";
o.CallbackPath = "/signin-microsoft";
o.SaveTokens = false;
});
}
}
builder.Services.AddAuthorization(opts =>
{
// Check the "role" claim explicitly — robust against role-claim-type mismatches between
@ -358,6 +407,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireGlobalFilterRegistrar>();
}
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
// Sprint 20: DB VACUUM ANALYZE + disk monitoring.
builder.Services.AddScoped<foodmarket.Api.Background.DatabaseMaintenanceJobs>();
builder.Services.AddScoped<foodmarket.Api.Background.DiskMonitoringJob>();
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
// Telegram-бот владельца. Token + username берём из конфига; если token

View file

@ -29,6 +29,9 @@
<PackageReference Include="prometheus-net.AspNetCore" />
<PackageReference Include="MediatR" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
<!-- Sprint 20: SSO scaffold (Google + Microsoft external auth). -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,83 @@
using Mapster;
using foodmarket.Application.Catalog;
using foodmarket.Domain.Catalog;
namespace foodmarket.Application.Mapping;
/// <summary>Sprint 20 / TD-3: централизованная Mapster-конфигурация
/// для проекций domain → DTO. Используется через
/// <c>queryable.ProjectToType&lt;TDto&gt;(MapsterConfig.Config)</c>
/// — Mapster кодогенерирует SQL-friendly `.Select(...)`-выражение,
/// что эквивалентно ручному `Select(p =&gt; new TDto(...))` по
/// производительности, но компактнее в контроллере.
///
/// Главное правило записи:
/// 1. Все computed-поля (joins, агрегаты) — через `.Map(...)`.
/// 2. Все коллекции (Prices, Barcodes) — через `.Map(...)` с
/// `.Adapt&lt;TItemDto&gt;()` на каждом элементе.
/// 3. PreserveReference = false (default) — для EF-проекций
/// циклы не нужны.
///
/// Регистрация — в `Program.cs`:
/// <code>
/// var cfg = MapsterConfig.Build();
/// services.AddSingleton(cfg);
/// services.AddScoped&lt;IMapper, ServiceMapper&gt;();
/// </code>
/// </summary>
public static class MapsterConfig
{
private static TypeAdapterConfig? _cached;
/// <summary>Singleton TypeAdapterConfig. Lazy-initialized чтобы
/// тесты тоже могли вызвать без DI.</summary>
public static TypeAdapterConfig Config => _cached ??= Build();
public static TypeAdapterConfig Build()
{
var cfg = new TypeAdapterConfig();
cfg.NewConfig<ProductBarcode, ProductBarcodeDto>()
.ConstructUsing(src => new ProductBarcodeDto(
src.Id, src.Code, src.Type, src.IsPrimary));
cfg.NewConfig<ProductPrice, ProductPriceDto>()
.ConstructUsing(src => new ProductPriceDto(
src.Id, src.PriceTypeId, src.PriceType!.Name,
src.Amount, src.CurrencyId, src.Currency!.Code));
cfg.NewConfig<Product, ProductDto>()
.ConstructUsing(src => new ProductDto(
src.Id, src.Name, src.Article, src.Description,
src.UnitOfMeasureId, src.UnitOfMeasure!.Name,
src.Vat, src.VatEnabled,
src.ProductGroupId, src.ProductGroup!.Name,
src.DefaultSupplierId,
src.DefaultSupplier != null ? src.DefaultSupplier.Name : null,
src.CountryOfOriginId,
src.CountryOfOrigin != null ? src.CountryOfOrigin.Name : null,
src.IsService, src.Packaging, src.IsMarked,
src.MinStock, src.MaxStock,
src.ReferencePrice, src.ReferencePriceUpdatedAt,
src.PurchaseCurrencyId,
src.PurchaseCurrency != null ? src.PurchaseCurrency.Code : null,
src.Cost, src.LastSupplyAt,
src.ImageUrl,
src.IsArchived, src.IsAvailableForSale,
src.Prices.Select(pr => new ProductPriceDto(
pr.Id, pr.PriceTypeId, pr.PriceType!.Name,
pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
src.Barcodes.Select(b => new ProductBarcodeDto(
b.Id, b.Code, b.Type, b.IsPrimary)).ToList()));
cfg.NewConfig<Counterparty, CounterpartyDto>()
.ConstructUsing(src => new CounterpartyDto(
src.Id, src.Name, src.LegalName, src.Type,
src.Bin, src.Iin, src.TaxNumber,
src.CountryId, src.Country != null ? src.Country.Name : null,
src.Address, src.Phone, src.Email,
src.BankName, src.BankAccount, src.Bik,
src.ContactPerson, src.Notes));
return cfg;
}
}

View file

@ -10,6 +10,17 @@ interface Props {
}
const { title, description = 'Программа учёта и касса для розничных магазинов в Казахстане. Бесплатно 90 дней.', ogImage = '/og/home.png' } = Astro.props
const canonical = new URL(Astro.url.pathname, Astro.site).toString()
// Sprint 20: analytics placeholders. Если в env заданы реальные ID
// (PUBLIC_GA_ID, PUBLIC_YM_ID — берутся из import.meta.env) — рендерим
// настоящий снипет. Иначе — комментарий-stub чтобы в HTML был визуальный
// маркер «здесь должна быть аналитика».
//
// Подключение: см. docs/analytics.md. В astro.config или .env положите:
// PUBLIC_GA_ID=G-XXXXXXXXXX (для Google Analytics 4)
// PUBLIC_YM_ID=12345678 (для Yandex.Metrika)
const gaId = import.meta.env.PUBLIC_GA_ID ?? 'REPLACE_ME'
const ymId = import.meta.env.PUBLIC_YM_ID ?? 'REPLACE_ME'
---
<!doctype html>
<html lang="ru-KZ">
@ -45,6 +56,37 @@ const canonical = new URL(Astro.url.pathname, Astro.site).toString()
operatingSystem: 'Web, Windows',
offers: { '@type': 'Offer', price: '5000', priceCurrency: 'KZT' },
})} />
{/* Sprint 20: analytics placeholders. См. docs/analytics.md.
Скрипты подключаются только при заданных env-vars; иначе
в HTML остаётся data-id="REPLACE_ME" — служит маркером
«не настроено» при ручной проверке view-source. */}
{gaId !== 'REPLACE_ME' ? (
<>
<script async src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}></script>
<script set:html={`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${gaId}', { anonymize_ip: true });
`} />
</>
) : (
<script data-analytics="google" data-id="REPLACE_ME" data-doc="docs/analytics.md" />
)}
{ymId !== 'REPLACE_ME' ? (
<script set:html={`
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym(${ymId}, "init", {clickmap:true,trackLinks:true,accurateTrackBounce:true});
`} />
) : (
<script data-analytics="yandex-metrika" data-id="REPLACE_ME" data-doc="docs/analytics.md" />
)}
</head>
<body class="min-h-screen flex flex-col">
<Header />