using System.Globalization;
using System.Text.Json;
namespace Server.WatchBody.Web.Dto;
/// Ошибка обработки пакета sync → HTTP-ответ с кодом и телом {"error": ...}.
public class SyncException(string message, int statusCode = 400, string code = "bad_request") : Exception(message)
{
public int StatusCode { get; } = statusCode;
public string Code { get; } = code;
}
/// Запись пакета sync (tech-requirements 2.1).
public class SyncItem
{
public Guid ClientGuid { get; set; }
public string Entity { get; set; } = string.Empty;
public string Op { get; set; } = string.Empty;
public JsonElement Data { get; set; }
}
public static class SyncItemParser
{
public static List Parse(string json)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json);
}
catch (JsonException e)
{
throw new SyncException($"невалидный JSON: {e.Message}");
}
using (doc)
{
if (doc.RootElement.ValueKind != JsonValueKind.Array)
throw new SyncException("ожидается JSON-массив записей");
var items = new List();
foreach (var element in doc.RootElement.EnumerateArray())
items.Add(ParseItem(element));
if (items.Count > 50)
throw new SyncException("пакет больше 50 записей");
return items;
}
}
private static SyncItem ParseItem(JsonElement element)
{
var item = new SyncItem();
if (!element.TryGetProperty("client_guid", out var guidEl) ||
!Guid.TryParse(guidEl.GetString(), out var guid))
throw new SyncException("client_guid отсутствует или не uuid");
item.ClientGuid = guid;
item.Entity = GetString(element, "entity");
item.Op = GetString(element, "op");
if (item.Op != "upsert")
throw new SyncException($"op '{item.Op}' не поддерживается (только upsert)");
if (!element.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object)
throw new SyncException("data отсутствует или не объект");
item.Data = data.Clone();
ValidateEntity(item);
return item;
}
private static string GetString(JsonElement element, string name, bool required = true)
{
if (!element.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String)
{
if (required) throw new SyncException($"поле '{name}' отсутствует или не строка");
return string.Empty;
}
return el.GetString() ?? string.Empty;
}
private static void ValidateEntity(SyncItem item)
{
var d = item.Data;
switch (item.Entity)
{
case "workout":
RequireDate(d, "started_at");
OneOf(d, "status", required: false, "active", "finished", "cancelled");
OneOf(d, "session_kind", required: false, "normal", "return", "deload", "test");
break;
case "workout_set":
RequireGuid(d, "workout_id");
RequireNum(d, "seq");
RequireNum(d, "weight_kg");
RequireNum(d, "reps");
RequireDate(d, "done_at");
OneOf(d, "source", required: true, "voice", "watch", "edit");
Range(d, "rpe", 1, 10);
Range(d, "knee_pain", 0, 10);
break;
case "exercise":
RequireString(d, "name");
OneOf(d, "group", required: true, "push", "pull", "legs", "core", "cardio");
break;
case "program_version":
RequireNum(d, "version"); // не используется (версию считает БД), но должен быть числом по схеме
break;
case "profile":
OneOf(d, "sex", required: false, "m", "f");
break;
case "daily_log":
RequireDate(d, "date");
Range(d, "wellbeing", 1, 5);
break;
case "break":
RequireDate(d, "started_on");
if (d.TryGetProperty("source", out var src) && src.ValueKind == JsonValueKind.String &&
src.GetString() == "auto")
throw new SyncException("break.source='auto' из sync не принимается — auto проставляет сервер");
OneOf(d, "kind", required: false, "illness", "vacation", "workload", "injury", "other");
break;
default:
throw new SyncException($"неизвестная entity '{item.Entity}'");
}
}
private static void RequireString(JsonElement d, string name)
{
if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String ||
string.IsNullOrWhiteSpace(el.GetString()))
throw new SyncException($"поле '{name}' обязательно");
}
private static void RequireNum(JsonElement d, string name)
{
if (!d.TryGetProperty(name, out var el) ||
(el.ValueKind != JsonValueKind.Number && el.ValueKind != JsonValueKind.Null))
throw new SyncException($"поле '{name}' должно быть числом");
}
private static void RequireGuid(JsonElement d, string name)
{
if (!d.TryGetProperty(name, out var el) || !Guid.TryParse(el.GetString(), out _))
throw new SyncException($"поле '{name}' отсутствует или не uuid");
}
private static void RequireDate(JsonElement d, string name)
{
if (!d.TryGetProperty(name, out var el) || !DateTime.TryParse(el.GetString(), out _))
throw new SyncException($"поле '{name}' отсутствует или не дата");
}
private static void OneOf(JsonElement d, string name, bool required, params string[] allowed)
{
if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String)
{
if (required) throw new SyncException($"поле '{name}' обязательно");
return;
}
var value = el.GetString();
if (value != null && !allowed.Contains(value))
throw new SyncException($"поле '{name}' = '{value}' вне допустимых значений");
}
private static void Range(JsonElement d, string name, int min, int max)
{
if (!d.TryGetProperty(name, out var el)) return;
if (el.ValueKind == JsonValueKind.Null) return;
if (el.ValueKind != JsonValueKind.Number || !el.TryGetInt32(out var value) || value < min || value > max)
throw new SyncException($"поле '{name}' вне диапазона {min}–{max}");
}
public static DateTime? UtcDateOrNull(JsonElement d, string name)
{
if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return null;
var parsed = DateTime.Parse(el.GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
return parsed.ToUniversalTime();
}
public static DateOnly? DayOrNull(JsonElement d, string name)
{
if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return null;
return DateOnly.Parse(el.GetString()!, CultureInfo.InvariantCulture);
}
public static int? IntOrNull(JsonElement d, string name)
{
if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number) return null;
return el.GetInt32();
}
public static decimal? DecimalOrNull(JsonElement d, string name)
{
if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number) return null;
return el.GetDecimal();
}
public static bool? BoolOrNull(JsonElement d, string name)
{
if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.True &&
el.ValueKind != JsonValueKind.False) return null;
return el.GetBoolean();
}
public static string? StringOrNull(JsonElement d, string name)
{
if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return null;
return el.GetString();
}
}