food-market/src/food-market.api/Controllers/DeviceTokensController.cs
nns 3547e823c3
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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
feat(notifications): задел push под iOS — device_tokens + IPushSender (заглушка)
Таблица токенов устройств (device_tokens, Phase40) + POST/DELETE /api/me/device-tokens
для регистрации iOS/Android/Web push-токенов. Контракт IPushSender.SendAsync; в DI
включён StubPushSender (пишет в лог) — реальный APNs/FCM/VAPID подменит заглушку, когда
появятся ключи, без правок вызывающего кода. Описано в docs/mobile-api.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 02:27:05 +05:00

70 lines
2.9 KiB
C#

using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers;
/// <summary>Регистрация токенов устройств для push (iOS/Android/Web). Приложение/PWA
/// вызывает после получения разрешения. Реальная отправка пока заглушка — см.
/// IPushSender/StubPushSender; таблица и регистрация заложены заранее.</summary>
[ApiController]
[Authorize]
[Route("api/me/device-tokens")]
public class DeviceTokensController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public DeviceTokensController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public record RegisterInput(string Platform, string Token);
/// <summary>Зарегистрировать/обновить токен устройства текущего пользователя.
/// Идемпотентно по токену (повторный вызов обновляет LastSeenAt/владельца).</summary>
[HttpPost]
public async Task<IActionResult> Register([FromBody] RegisterInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
var userId = _tenant.UserId;
if (orgId is null || userId is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(input.Token)) return BadRequest(new { error = "Пустой токен." });
var platform = string.IsNullOrWhiteSpace(input.Platform) ? "ios" : input.Platform.Trim().ToLowerInvariant();
var now = DateTime.UtcNow;
var existing = await _db.DeviceTokens.FirstOrDefaultAsync(t => t.Token == input.Token, ct);
if (existing is null)
{
_db.DeviceTokens.Add(new DeviceToken
{
OrganizationId = orgId.Value, UserId = userId.Value,
Platform = platform, Token = input.Token.Trim(), LastSeenAt = now,
});
}
else
{
existing.OrganizationId = orgId.Value;
existing.UserId = userId.Value;
existing.Platform = platform;
existing.LastSeenAt = now;
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
/// <summary>Удалить токен (при выходе/отзыве разрешения).</summary>
[HttpDelete]
public async Task<IActionResult> Unregister([FromQuery] string token, CancellationToken ct)
{
var existing = await _db.DeviceTokens.FirstOrDefaultAsync(t => t.Token == token, ct);
if (existing is not null) { _db.DeviceTokens.Remove(existing); await _db.SaveChangesAsync(ct); }
return NoContent();
}
}