using Azaion.Common;
using Azaion.Common.Database;
using Azaion.Common.Entities;
using LinqToDB;
namespace Azaion.Services;
///
/// AZ-535 — logout/revocation surface. Distinct from :
/// refresh-token service rotates and reuse-detects; this service expresses the
/// human / admin / system intent to kill a session and exposes the verifier-poll
/// snapshot that powers cross-service denylists.
///
public interface ISessionService
{
///
/// Revoke a single session by id. Returns the revocation status BEFORE this
/// call: true if it was already revoked (idempotent no-op),
/// false if this call is the one that revoked it.
///
Task RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default);
///
/// Revoke every active session for a user. Returns the count of rows newly
/// revoked by this call.
///
Task RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default);
///
/// AZ-533 — auto-revoke every open mission session belonging to .
/// Fired on successful /login or /token/refresh from the aircraft's own user.
///
Task RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default);
///
/// AZ-535 AC-4 — verifier-poll snapshot. Returns sessions revoked since
/// whose exp is still in the future, so the
/// list stays bounded.
///
Task> GetRevokedSince(DateTime since, CancellationToken ct = default);
}
public sealed record RevokedSession(Guid Sid, DateTime Exp, DateTime RevokedAt, string? Reason);
public class SessionService(IDbFactory dbFactory) : ISessionService
{
public async Task RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default)
{
return await dbFactory.RunAdmin(async db =>
{
var existing = await db.Sessions.FirstOrDefaultAsync(s => s.Id == sessionId, token: ct);
if (existing == null)
throw new BusinessException(ExceptionEnum.SessionNotFound);
if (existing.RevokedAt.HasValue)
return true; // idempotent — already revoked, no DB write needed
var now = DateTime.UtcNow;
await db.Sessions
.Where(s => s.Id == sessionId && s.RevokedAt == null)
.Set(s => s.RevokedAt, now)
.Set(s => s.RevokedReason, reason)
.Set(s => s.RevokedByUserId, byUserId)
.UpdateAsync(token: ct);
return false;
});
}
public async Task RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default) =>
await dbFactory.RunAdmin(async db =>
{
var now = DateTime.UtcNow;
return await db.Sessions
.Where(s => s.UserId == userId && s.RevokedAt == null)
.Set(s => s.RevokedAt, now)
.Set(s => s.RevokedReason, reason)
.Set(s => s.RevokedByUserId, byUserId)
.UpdateAsync(token: ct);
});
public async Task RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default) =>
await dbFactory.RunAdmin(async db =>
{
var now = DateTime.UtcNow;
return await db.Sessions
.Where(s => s.AircraftId == aircraftId
&& s.Class == SessionClasses.Mission
&& s.RevokedAt == null)
.Set(s => s.RevokedAt, now)
.Set(s => s.RevokedReason, SessionRevokedReasons.PostFlightReconnect)
.UpdateAsync(token: ct);
});
public async Task> GetRevokedSince(DateTime since, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
return await dbFactory.Run(async db =>
(await db.Sessions
.Where(s => s.RevokedAt != null
&& s.RevokedAt > since
&& s.ExpiresAt > now) // AZ-535 AC-4: prune expired
.Select(s => new RevokedSession(s.Id, s.ExpiresAt, s.RevokedAt!.Value, s.RevokedReason))
.ToListAsync(token: ct)));
}
}