using System.Globalization; using System.Text; namespace Nexus.Api.Services; internal readonly record struct TaskBoardCursorPosition( DateTimeOffset UpdatedAt, Guid Id); internal static class TaskBoardCursorCodec { private const string Version = "v1"; private const int MaximumEncodedLength = 128; public static string Encode(DateTimeOffset updatedAt, Guid id) { var payload = string.Create( CultureInfo.InvariantCulture, $"{Version}|{updatedAt.UtcDateTime.Ticks}|{id:N}"); return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); } public static bool TryDecode(string? cursor, out TaskBoardCursorPosition position) { position = default; if (string.IsNullOrWhiteSpace(cursor) || cursor.Length > MaximumEncodedLength) return false; try { var normalized = cursor .Replace('-', '+') .Replace('_', '/'); normalized = (normalized.Length % 4) switch { 0 => normalized, 2 => normalized + "==", 3 => normalized + "=", _ => throw new FormatException("Invalid Base64Url length.") }; var payload = Encoding.UTF8.GetString(Convert.FromBase64String(normalized)); var parts = payload.Split('|'); if (parts.Length != 3 || !string.Equals(parts[0], Version, StringComparison.Ordinal) || !long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var utcTicks) || !Guid.TryParseExact(parts[2], "N", out var id)) { return false; } var updatedAt = new DateTimeOffset(utcTicks, TimeSpan.Zero); position = new TaskBoardCursorPosition(updatedAt, id); return true; } catch (Exception exception) when ( exception is FormatException or ArgumentOutOfRangeException or DecoderFallbackException) { return false; } } } public sealed class InvalidTaskBoardCursorException() : FormatException("The Done cursor is invalid or unsupported.");