Sprint 15 финальный — реальные axe + coverage + pg_restore numbers.
Ключевые цифры:
- axe-core: critical=0 on 10 страниц stage'а; serious 12→9
после фиксов (sidebar contrast + 8 icon-only back-arrow aria-labels).
- Unit coverage: Application 56%→83%, Domain 11%→79%, combined
60%→80%. Тестов 68→147 (+79).
- Backup recovery drill: RTO ~25 секунд end-to-end
(pg_dump 2s + pg_restore 4s + dotnet startup 19s).
Что сделано:
1. @axe-core/playwright + stage-ui-15 (10 страниц) + stage-ui-16
(SR smoke на login: getByLabel, role=alert, aria-describedby,
keyboard nav).
2. useFocusTrap hook (WCAG 2.4.3 + 2.1.2): return-focus, mount-focus,
Tab cycle. Подключён к Modal + ConfirmDialog с opt-in
defaultFocus='cancel'|'confirm'. ConfirmDialog по дефолту фокусит
Cancel для destructive actions (safer чем Enter→Delete).
3. A11y фиксы:
• text-slate-400→text-slate-500 в sidebar (contrast 2.63→4.61).
• 8 страниц edit с back-arrow Link — aria-label + aria-hidden
на иконке + текст-slate-500 цвет.
• Modal close button — то же.
• LoginPage — aria-invalid/aria-describedby/role=alert на
ошибках валидации.
• Field component — role="alert" на error span (announce'ит SR).
4. 8 файлов unit-тестов: PhoneNormalization, PagedRequest,
RequiredGuid, RolePermissions (Domain), DomainPocoSmoke,
DomainFullPropertyTouch, CatalogDtosSmoke, StockServiceProperty
(4 seeds × 4 size + batch + 2-product isolation).
5. Backup-drill: pg_dump со stage'а → fresh postgres:16-alpine →
pg_restore → dotnet run против восстановленной БД → /health/ready
Healthy. Команды и timing в RUNBOOK.md.
6. Docs review:
• MULTI-TENANCY чеклист «добавить tenant-сущность» расширен с 6
до 19 шагов (Domain → EF Config → Migration с Xmin →
RolePermissions → Validation → Controller + RequiresPermission →
Audit + SensitiveOpsAudit → property tests).
• ARCHITECTURE.md — Sprint 13-15 changes таблица.
• DEVELOPER-GUIDE.md — «что добавилось после первого guide'а» +
a11y pitfalls в «что НЕ делать».
Stage smoke ✓. Это финальный автономно-безопасный спринт. Дальше
нужен вход от user'а (ОФД keys, MoySklad tokens, Windows для POS,
прод-деплой план, kz-перевод, реальный SMTP).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
417 lines
22 KiB
Markdown
417 lines
22 KiB
Markdown
# Multi-tenancy в food-market
|
||
|
||
Один процесс API, одна БД, много организаций (тенантов). Каждый запрос
|
||
видит только данные своей организации. Изоляция держится на двух вещах:
|
||
|
||
1. **EF Core query filter** на каждой `ITenantEntity` (auto-инжектится
|
||
в `WHERE` каждого SQL-запроса).
|
||
2. **Stamping в SaveChanges** — добавляемые сущности получают
|
||
`OrganizationId` из текущего `ITenantContext`.
|
||
|
||
`SuperAdmin` — отдельная роль с правом обходить фильтр (видеть/менять
|
||
всё). Чтобы не получить «случайные изменения по всем оргам сразу»,
|
||
есть строгий режим «открыть как…» с двумя ступенями (read-only +
|
||
edit-mode с reason).
|
||
|
||
## Модель
|
||
|
||
### Базовые интерфейсы
|
||
|
||
```csharp
|
||
// food-market.domain/Common/TenantEntity.cs
|
||
|
||
public interface ITenantEntity // обязательный orgId
|
||
{
|
||
Guid OrganizationId { get; set; }
|
||
}
|
||
|
||
public abstract class TenantEntity : Entity, ITenantEntity
|
||
{
|
||
public Guid OrganizationId { get; set; }
|
||
}
|
||
|
||
public interface IOptionalTenantEntity // системный справочник
|
||
{
|
||
Guid? OrganizationId { get; set; }
|
||
}
|
||
```
|
||
|
||
### Когда использовать что
|
||
|
||
| Случай | База |
|
||
|---|---|
|
||
| Бизнес-данные тенанта: продукты, чеки, контрагенты, отчёты | `TenantEntity` (обязательный orgId) |
|
||
| Системные справочники с возможностью per-tenant расширения: `UnitOfMeasure`, `ProductGroup`, `Country` | `IOptionalTenantEntity` (null = системная, оrgId = tenant'овская) |
|
||
| Корневая сущность тенанта: `Organization` | `Entity` (сама не tenant-scoped) |
|
||
| Платформенные настройки: `PlatformSettings` (SMTP), OpenIddict-клиенты | `Entity` (singletons / cross-tenant) |
|
||
| Audit-логи SuperAdmin'а: `SuperAdminAuditLog` | `Entity` (есть optional `OrganizationId` для фильтра, но сама не tenant-scoped) |
|
||
|
||
## Tenant-контекст
|
||
|
||
`ITenantContext` (Application слой) — единственный источник правды о
|
||
том, кто сейчас делает запрос:
|
||
|
||
```csharp
|
||
// food-market.application/Common/Tenancy/ITenantContext.cs
|
||
public interface ITenantContext
|
||
{
|
||
Guid? OrganizationId { get; } // null = SuperAdmin вне override, или нет JWT
|
||
bool IsAuthenticated { get; }
|
||
bool IsSuperAdmin { get; }
|
||
bool IsTenantOverride { get; } // SuperAdmin в режиме «открыть как…»
|
||
Guid? UserId { get; }
|
||
}
|
||
```
|
||
|
||
Реализация — `HttpContextTenantContext` (`food-market.api/Infrastructure/Tenancy/`).
|
||
Источники данных в порядке приоритета:
|
||
|
||
1. **AsyncLocal-override** (`UseOverride(orgId, isSuper)`) — для
|
||
background-tasks (Hangfire, импорт MoySklad, фоновые сидеры).
|
||
Когда нет HttpContext, нужно явно задать tenant перед `DbContext`-вызовом.
|
||
2. **HTTP-заголовок `X-Org-Override`** — режим «открыть как…»
|
||
(только если у юзера роль SuperAdmin).
|
||
3. **JWT claim `org_id`** — обычный tenant-юзер.
|
||
|
||
```csharp
|
||
// background-job пример
|
||
using (HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false))
|
||
{
|
||
// здесь _db применит фильтр на orgId
|
||
var products = await _db.Products.ToListAsync();
|
||
}
|
||
```
|
||
|
||
## Query filter
|
||
|
||
`AppDbContext.OnModelCreating` после регистрации всех сущностей
|
||
рефлексией обходит модель и вешает фильтр на каждую `ITenantEntity`:
|
||
|
||
```csharp
|
||
// food-market.infrastructure/Persistence/AppDbContext.cs
|
||
|
||
private void ApplyTenantFilter<T>(ModelBuilder builder) where T : class, ITenantEntity
|
||
{
|
||
// SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…».
|
||
// В override-режиме (X-Org-Override header активен) он работает в
|
||
// контексте конкретной орги — фильтр обязан применяться.
|
||
builder.Entity<T>().HasQueryFilter(e =>
|
||
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|
||
|| e.OrganizationId == _tenant.OrganizationId);
|
||
}
|
||
|
||
private void ApplyOptionalTenantFilter<T>(ModelBuilder builder) where T : class, IOptionalTenantEntity
|
||
{
|
||
builder.Entity<T>().HasQueryFilter(e =>
|
||
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|
||
|| e.OrganizationId == null
|
||
|| e.OrganizationId == _tenant.OrganizationId);
|
||
}
|
||
```
|
||
|
||
Результат:
|
||
|
||
- Tenant-юзер: `WHERE OrganizationId = '<его orgId>'`.
|
||
- SuperAdmin без override: фильтр не применяется (видит всё).
|
||
- SuperAdmin с `X-Org-Override`: `WHERE OrganizationId = '<выбранная orgId>'`.
|
||
- `IOptionalTenantEntity`: видит свои + системные (`IS NULL`).
|
||
|
||
## Stamping в SaveChanges
|
||
|
||
`AppDbContext.SaveChanges/Async` зовут `StampTenant()`, который
|
||
проходит по `Added`-entries:
|
||
|
||
```csharp
|
||
private void StampTenant()
|
||
{
|
||
foreach (var entry in ChangeTracker.Entries())
|
||
{
|
||
if (entry.State != EntityState.Added) continue;
|
||
if (entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty)
|
||
{
|
||
if (_tenant.OrganizationId.HasValue)
|
||
tenant.OrganizationId = _tenant.OrganizationId.Value;
|
||
}
|
||
else if (entry.Entity is IOptionalTenantEntity opt && opt.OrganizationId is null)
|
||
{
|
||
// SuperAdmin без override: оставляем null (системная запись)
|
||
// SuperAdmin с override / tenant-юзер: стампим текущий orgId
|
||
if (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) { /* null */ }
|
||
else if (_tenant.OrganizationId.HasValue)
|
||
opt.OrganizationId = _tenant.OrganizationId.Value;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Это значит: контроллер может писать `_db.Products.Add(product)` без
|
||
явного `product.OrganizationId = ...` — stamping подставит сам.
|
||
**НО**: если код явно выставил `OrganizationId` (например, чтобы создать
|
||
запись для другой орги в Hangfire-job), stamping её не перетрёт.
|
||
|
||
## SuperAdmin override: режим «открыть как…»
|
||
|
||
Конкретный поток с фронта:
|
||
|
||
1. SuperAdmin заходит в «Системная консоль → Организации».
|
||
2. Кликает «Открыть как…» на какой-то orgRow.
|
||
3. Фронт начинает слать каждый запрос с заголовком
|
||
`X-Org-Override: <orgId>`. Без этого хедера SuperAdmin видит «своё»
|
||
(а у SuperAdmin'а часто нет своей орги, поэтому все списки пусты —
|
||
у супер-админа в админке тренировочный режим).
|
||
4. По умолчанию режим **read-only** (`ReadonlyOverrideMiddleware`):
|
||
GET/HEAD/OPTIONS — пропускаются; PUT/POST/DELETE/PATCH — `403`.
|
||
Исключение: `/api/super-admin/*` и `/connect/*` (refresh-token).
|
||
5. Чтобы что-то поменять, фронт показывает «Войти в edit-mode»,
|
||
запрашивает причину (≥ 10 символов), отправляет её в каждом запросе
|
||
как `X-Org-Override-Reason: <текст>`. Тогда middleware пропускает
|
||
мутации, а action-filter (`SuperAdminEditAuditFilter`) после успешного
|
||
ответа пишет строку в `super_admin_audit_log` с reason'ом и
|
||
запросом/ответом.
|
||
6. Фронт ограничивает edit-mode 30 минутами (UI таймер).
|
||
Сервер не следит за временем — это UX-конвенция, а аудит уже есть.
|
||
|
||
### ClaimsTransformer для tenant-ролей
|
||
|
||
Тонкость: SuperAdmin сам по себе не имеет ролей `Admin/Storekeeper/Cashier`
|
||
(они — атрибуты `Employee` тенанта). Контроллер
|
||
`[Authorize(Roles="Admin")]` отшил бы его 403 даже в edit-mode.
|
||
|
||
Решение: `SuperAdminOverrideClaimsTransformer` (`IClaimsTransformation`,
|
||
вызывается на каждый authenticated request) — если есть `X-Org-Override`,
|
||
динамически добавляет SuperAdmin'у `Admin/Storekeeper/Cashier` claim-роли
|
||
**только на текущий запрос**. Записи в БД не трогает.
|
||
|
||
## RequiresPermission: тонкая авторизация
|
||
|
||
Для мутаций используется `[RequiresPermission("...")]` вместо
|
||
`[Authorize(Roles="Admin,Storekeeper")]`. Атрибут резолвится через
|
||
policy-механизм:
|
||
|
||
```
|
||
[RequiresPermission("ProductsEdit")]
|
||
→ Policy "perm:ProductsEdit"
|
||
→ PermissionAuthorizationPolicyProvider создаёт PermissionRequirement
|
||
→ PermissionAuthorizationHandler проверяет
|
||
EmployeeRole.RolePermissions.ProductsEdit == true для текущего юзера
|
||
```
|
||
|
||
`RolePermissions` — это POCO с булевыми полями (`ProductsView`,
|
||
`ProductsEdit`, `RetailSalesOperate`, `RetailSalesRefund`, …).
|
||
`Employee.EmployeeRoleId` указывает на конкретную роль, у каждой —
|
||
свой набор флагов. SuperAdmin (с override) проходит всегда.
|
||
|
||
См. `food-market.api/Infrastructure/Authorization/`.
|
||
|
||
## Audit-trail
|
||
|
||
### `org_audit_log`
|
||
|
||
Каждая `Add/Update/Delete` в `AppDbContext` (через
|
||
`OrgAuditInterceptor`) пишет JSONB-diff в `org_audit_log`:
|
||
|
||
```json
|
||
{
|
||
"id": "...",
|
||
"organizationId": "...",
|
||
"userId": "...",
|
||
"entityType": "Product",
|
||
"entityId": "...",
|
||
"action": "Update",
|
||
"changesJson": { "before": {...}, "after": {...} },
|
||
"createdAt": "..."
|
||
}
|
||
```
|
||
|
||
Фоновый Hangfire-job `prune-audit-log` (03:45) чистит записи старше
|
||
180 дней.
|
||
|
||
Сидеры/миграции выставляют `_db.SkipAudit = true` чтобы не плодить
|
||
бессмысленные строки.
|
||
|
||
### `super_admin_audit_log`
|
||
|
||
Только мутации SuperAdmin'а в edit-mode, плюс изменения платформенных
|
||
настроек. Без TTL — храним всё.
|
||
|
||
## Известные подводные камни
|
||
|
||
### 1. `IgnoreQueryFilters()` — когда нужно знать
|
||
|
||
Тенант-фильтр применяется ко ВСЕМУ — в том числе там, где это «не надо».
|
||
|
||
- При логине: ищем `Organization` по `OrgId` из credentials → нужен
|
||
`IgnoreQueryFilters()`, потому что фильтр требует OrganizationId,
|
||
которого ещё нет в контексте.
|
||
- При проверке «есть ли у юзера эта орг»: `_db.Organizations.IgnoreQueryFilters().AnyAsync(...)`.
|
||
- При cross-tenant отчётах SuperAdmin'а без override-режима фильтр и так
|
||
не применится, но при override — применится; чтобы получить
|
||
cross-tenant данные в этом режиме (редко нужно), вызвать
|
||
`IgnoreQueryFilters()` явно.
|
||
|
||
### 2. Stamping не работает, если orgId уже задан
|
||
|
||
Бэкенд-код в нескольких местах принимает `Guid OrganizationId` из payload
|
||
(например, при импорте). Stamping проверяет `OrganizationId == Guid.Empty`
|
||
и НЕ перетирает уже выставленное значение. Если кто-то по ошибке прислал
|
||
чужой orgId в payload — он сохранится. Защита: явно валидировать
|
||
`OrganizationId == _tenant.OrganizationId` в контроллере (или вообще не
|
||
принимать поле из payload).
|
||
|
||
### 3. Background-jobs без HttpContext
|
||
|
||
`HangfireJobsConfigurator` регистрирует методы, исполняющиеся в фоне.
|
||
`HttpContextAccessor.HttpContext` там null → `OrganizationId` тоже null →
|
||
query filter возвращает только записи с `OrganizationId == null` (т.е.
|
||
системные справочники), а tenant-запросы — пустоту.
|
||
|
||
Решение: внутри job, перед `DbContext`-вызовом, обернуть в
|
||
`HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false)`.
|
||
См. `OwnerDailySummaryJob`, `EmailNotificationJobs` — там есть пример.
|
||
|
||
### 4. SignalR за query-фильтром
|
||
|
||
`NotificationsHub` использует `IServiceProvider.GetRequiredService<AppDbContext>()`
|
||
внутри `OnConnectedAsync` для добавления соединения в group. Если в
|
||
connection нет JWT — нет org_id — нет группы → клиент не получит
|
||
событий. Web-фронт прокидывает `?access_token=...` query (см. middleware
|
||
в Program.cs), POS — `Authorization` header.
|
||
|
||
### 5. EF migrations и наследование от TenantEntity
|
||
|
||
При добавлении новой `TenantEntity` миграция должна включать
|
||
`OrganizationId` колонку (uuid, NOT NULL) и индекс
|
||
`(OrganizationId, …)` для фильтрации. **Эта колонка не появляется
|
||
автоматически в snapshot-выводе `dotnet ef migrations add`** в этом
|
||
проекте, потому что снапшот не синхронизируется с моделью (миграции
|
||
пишутся руками, см. memory `feedback_ef_migrations`).
|
||
|
||
Проверка: после `Migrate()` тестовый запрос
|
||
`SELECT column_name FROM information_schema.columns WHERE table_name='...'`
|
||
должен показать `OrganizationId`.
|
||
|
||
### 6. `xmin` concurrency и параллельные посты
|
||
|
||
`UseXminAsConcurrencyToken()` на документах. Если два кассира одновременно
|
||
постят один и тот же чек (что не должно случаться, но всё же) — второй
|
||
получает `DbUpdateConcurrencyException`. Контроллер ловит и возвращает
|
||
`409 Conflict` с сообщением «документ изменён в другой сессии, обновите
|
||
страницу».
|
||
|
||
Stock-операции — отдельная история: `Serializable` транзакция блокирует
|
||
параллельный пост на тех же товарах в том же storе. Серверу `PG40001`
|
||
(serialization_failure) — контроллер не ретраит автоматически, кассир
|
||
видит 409 «недостаточно остатка» (после ретрая по факту достаточно или
|
||
нет).
|
||
|
||
### 7. Тестирование изоляции
|
||
|
||
`TenantIsolationTests` (integration) — обязательный смок: создаём 2
|
||
организации, в одной — продукт; в другой делаем `GET /api/catalog/products`
|
||
→ список пустой. На любую новую `ITenantEntity` добавлять такой тест.
|
||
|
||
### 8. Read-models / ad-hoc raw SQL
|
||
|
||
Если контроллер напишет raw SQL через `_db.Database.SqlQueryRaw(...)`,
|
||
EF-фильтр НЕ применится. Это используется только для отчётов с тяжёлой
|
||
агрегацией (`ProfitReportController`); там OrganizationId явно
|
||
включается в `WHERE` и приходит из `_tenant.OrganizationId`.
|
||
Правило: никаких raw SQL без явного `WHERE OrganizationId = @org` в
|
||
середине запроса.
|
||
|
||
## Чеклист «как добавить новую tenant-сущность»
|
||
|
||
Расширенная версия с RowVersion + permission + validation паттернами
|
||
(Sprint 15). Минимальный «список из 6 пунктов» оставлен ниже как краткая
|
||
форма.
|
||
|
||
### Domain
|
||
|
||
1. Унаследовать от `TenantEntity` (или реализовать `ITenantEntity`).
|
||
2. Для **документов** (Supply, RetailSale, Loss…) — добавить
|
||
`IVersionedEntity` + `uint Xmin { get; set; }` для оптимистической
|
||
блокировки через PG xmin. EF переведёт concurrency-конфликт в
|
||
`DbUpdateConcurrencyException`, контроллер вернёт 409.
|
||
|
||
### Infrastructure (EF Config + миграция)
|
||
|
||
3. Добавить EF Configuration в
|
||
`food-market.infrastructure/Persistence/Configurations/`:
|
||
- `b.ToTable("snake_case");`
|
||
- Для документа: `b.UseXminAsConcurrencyToken(); b.Ignore(x => x.Xmin);`
|
||
- `b.Property(x => x.Number).HasMaxLength(50).IsRequired();` —
|
||
явные ограничения вместо EF-defaults.
|
||
- `b.Property(x => x.SomeDecimal).HasPrecision(18, 4);` — иначе EF
|
||
warning'и про missing precision.
|
||
- **Индекс с `OrganizationId` первым полем**:
|
||
`b.HasIndex(x => new { x.OrganizationId, x.SomeField });`.
|
||
- Уникальность в рамках org: `.IsUnique()` на том же composite-индексе.
|
||
- Sprint 14: для статусов-документов, по которым строятся отчёты —
|
||
ещё один composite `(OrganizationId, Status, Date)` или partial
|
||
индекс `WHERE Status = X AND NOT Y` с `INCLUDE` для covering.
|
||
|
||
4. Создать миграцию руками в `Persistence/Migrations/`:
|
||
- Атрибуты `[DbContext(typeof(AppDbContext))]` + `[Migration("YYYYMMDDHHMMSS_NameHere")]`.
|
||
**Без них `Migrate()` миграцию не подхватит** (см. memory
|
||
`feedback_ef_migrations`).
|
||
- В `Up()` — `CreateTable` с колонкой `OrganizationId uuid NOT NULL`.
|
||
- Индексы (минимум один на OrganizationId).
|
||
- Не использовать `dotnet ef migrations add` — снапшот в репо
|
||
не синхронизируется с моделью.
|
||
|
||
5. Добавить `DbSet<TEntity>` в `AppDbContext`.
|
||
|
||
### Permission (RolePermissions)
|
||
|
||
6. Добавить булевый флаг в `RolePermissions.cs`:
|
||
`public bool MyEntityEdit { get; set; }` + соответствующая запись в
|
||
`All()` фабрике (для системной роли Admin).
|
||
7. Миграции для `role_permissions` не нужно — это JSONB-колонка
|
||
на `EmployeeRole`.
|
||
8. Все Admin-роли уже получат новый permission через `RolePermissions.All()`.
|
||
|
||
### Validation
|
||
|
||
9. Для простой валидации — DataAnnotations на input-record'е:
|
||
`public record Input([Required, MaxLength(200)] string Name, …);`
|
||
10. Для сложной — `FluentValidation` в
|
||
`food-market.api/Infrastructure/Validation/Validators.cs`:
|
||
- `public sealed class MyInputValidator : AbstractValidator<MyInput>`
|
||
с `RuleFor`/`RuleForEach`.
|
||
- Регистрируется автоматически (assembly-scan на старте).
|
||
- `ValidationFilter` в pipeline'е вызовет валидатор и вернёт 400
|
||
ProblemDetails (RFC 7807) до Action'а.
|
||
11. Бизнес-валидация (требует БД — «существует ли поставщик»): в начале
|
||
action-метода вернуть `BadRequest(new { error, field })`.
|
||
|
||
### Controller
|
||
|
||
12. Контроллер использует `_db.MyEntities.Where(...)` — query filter
|
||
подключится автоматически. `StampTenant` в `SaveChangesAsync`
|
||
выставит `OrganizationId` в `Add()`.
|
||
13. Защитить mutating endpoint'ы атрибутом
|
||
`[RequiresPermission("MyEntityEdit")]` (резолвится в policy
|
||
`perm:MyEntityEdit` → проверяет флаг на `RolePermissions`).
|
||
14. Для concurrency-чувствительных мутаций (Post документа):
|
||
`await using var tx = await _db.Database.BeginTransactionAsync(
|
||
IsolationLevel.Serializable, ct)` — защита от race на остатке.
|
||
|
||
### Audit (Sprint 13)
|
||
|
||
15. CRUD автоматически логируется `OrgAuditInterceptor`'ом в
|
||
`org_audit_log` (JSON diff).
|
||
16. Для sensitive-операций (смена пароля, выдача роли, изменение
|
||
permissions) — дополнительно через
|
||
`SensitiveOpsAudit.LogAsync()` — она пишет в `org_audit_log` +
|
||
Serilog с типизированным action-name.
|
||
|
||
### Tests
|
||
|
||
17. **Интеграционный тест на изоляцию** (`TenantIsolationTests`):
|
||
org A создаёт, org B делает GET — список пустой, GET-by-id → 404.
|
||
18. Если есть concurrency-критика (Post под Serializable):
|
||
`RetailOversellingTests`-pattern — два параллельных VU гарантированно
|
||
дают 409 на одном из них.
|
||
19. Для бизнес-инварианта (типа «Sum(movements) ≡ Stock»):
|
||
property-based test (см. `StockServicePropertyTests`, Sprint 15).
|