145 lines
8.4 KiB
C#
145 lines
8.4 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Net.Http.Headers;
|
||
using System.Text.Json;
|
||
using Microsoft.Data.Sqlite;
|
||
|
||
namespace Wiki.Api;
|
||
|
||
/// <summary>Человек или агент в ответах API: автор и последний редактор.</summary>
|
||
public sealed record Person(string Id, string? Name, string? Email, string? AvatarUrl, string? Color);
|
||
|
||
/// <summary>
|
||
/// Профили берём там же, где CRM: довідник виконавців — JSON в app_config (id = 'main'), поле data.assignees[]
|
||
/// ({ id, name, email, uid, avatar, color }). avatar — публичный URL бакета Storage `logos` (avatars/<id>).
|
||
/// Запрос идёт с токеном пользователя (RLS). Обновляем не чаще раза в 10 минут на пользователя.
|
||
/// </summary>
|
||
public sealed class Profiles(Db db, IHttpClientFactory http, IConfiguration cfg)
|
||
{
|
||
private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/');
|
||
private readonly string _key = cfg["Supabase:PublishableKey"] ?? "";
|
||
private readonly ConcurrentDictionary<string, DateTimeOffset> _fresh = new();
|
||
private static readonly TimeSpan RefreshEvery = TimeSpan.FromMinutes(10);
|
||
|
||
private sealed record Assignee(string? Id, string? Uid, string? Name, string? Email, string? Avatar, string? Color)
|
||
{
|
||
/// <summary>Цвет кружка как в CRM: свой или подобранный по id записи (avatarHtml → colorOf).</summary>
|
||
public string? Circle => SafeColor(Color) ?? (string.IsNullOrEmpty(Id) ? null : ColorOf(Id));
|
||
}
|
||
|
||
/// <summary>Палитра и алгоритм — копия colorOf из CRM (js/util.js), чтобы цвета совпадали.</summary>
|
||
private static readonly string[] AvatarColors =
|
||
["#FFCA40", "#7DD3FC", "#A78BFA", "#4ADE80", "#FB923C", "#F472B6", "#F97066", "#5EEAD4", "#C4B5FD", "#E5E5E5"];
|
||
public static string ColorOf(string s)
|
||
{
|
||
long h = 0;
|
||
for (var i = 0; i < s.Length; i += char.IsSurrogatePair(s, i) ? 2 : 1)
|
||
h = (h * 31 + char.ConvertToUtf32(s, i)) % 9973;
|
||
return AvatarColors[h % AvatarColors.Length];
|
||
}
|
||
|
||
public async Task EnsureAsync(WikiUser u, string token, CancellationToken ct)
|
||
{
|
||
var now = DateTimeOffset.UtcNow;
|
||
if (_fresh.TryGetValue(u.Id, out var until) && until > now) return;
|
||
_fresh[u.Id] = now + RefreshEvery;
|
||
|
||
var team = await FetchTeamAsync(token, ct);
|
||
var email = u.Email?.Trim().ToLowerInvariant();
|
||
var mine = team?.FirstOrDefault(a => a.Uid == u.Id)
|
||
?? team?.FirstOrDefault(a => email is not null && a.Email?.Trim().ToLowerInvariant() == email);
|
||
var name = Clean(mine?.Name) ?? Clean(u.Name) ?? Clean(u.Email?.Split('@')[0]);
|
||
|
||
using var c = db.Open();
|
||
using var tx = c.BeginTransaction();
|
||
// Свой профиль: из CRM, если нашёлся; иначе из токена (бот без записи в довіднику — тоже автор).
|
||
Upsert(c, u.Id, name, u.Email ?? mine?.Email, mine is null ? null : Avatar(mine.Avatar), mine?.Circle, keepVisual: team is null);
|
||
// Заодно обновляем остальных из довідника (кто уже входил в CRM — у них есть uid).
|
||
if (team is not null)
|
||
foreach (var a in team.Where(a => !string.IsNullOrEmpty(a.Uid) && a.Uid != u.Id))
|
||
Upsert(c, a.Uid!, Clean(a.Name), a.Email, Avatar(a.Avatar), a.Circle, keepVisual: false, onlyExisting: true);
|
||
tx.Commit();
|
||
}
|
||
|
||
private async Task<List<Assignee>?> FetchTeamAsync(string token, CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/rest/v1/app_config?id=eq.main&select=data");
|
||
req.Headers.Add("apikey", _key);
|
||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
|
||
if (!r.IsSuccessStatusCode) return null;
|
||
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
|
||
if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0) return null;
|
||
var row = doc.RootElement[0];
|
||
if (!row.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return null;
|
||
if (!data.TryGetProperty("assignees", out var list) || list.ValueKind != JsonValueKind.Array) return null;
|
||
return list.EnumerateArray().Where(a => a.ValueKind == JsonValueKind.Object)
|
||
.Select(a => new Assignee(Str(a, "id"), Str(a, "uid"), Str(a, "name"), Str(a, "email"), Str(a, "avatar"), Str(a, "color")))
|
||
.ToList();
|
||
}
|
||
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return null; }
|
||
}
|
||
|
||
/// <param name="keepVisual">не трогать аватар и цвет (довідник не прочитали)</param>
|
||
/// <param name="onlyExisting">только обновить уже известных вики (не заводить всех из довідника)</param>
|
||
private static void Upsert(SqliteConnection c, string uid, string? name, string? email, string? avatar, string? color,
|
||
bool keepVisual, bool onlyExisting = false)
|
||
{
|
||
using var cmd = c.CreateCommand();
|
||
cmd.CommandText = onlyExisting
|
||
? """
|
||
UPDATE users SET name = coalesce($n, name), email = coalesce($e, email), avatar_url = $a, color = $c, updated_at = $now
|
||
WHERE uid = $u
|
||
"""
|
||
: keepVisual
|
||
? """
|
||
INSERT INTO users(uid, name, email, updated_at) VALUES ($u, $n, $e, $now)
|
||
ON CONFLICT(uid) DO UPDATE SET name = coalesce(excluded.name, name), email = coalesce(excluded.email, email),
|
||
updated_at = excluded.updated_at
|
||
"""
|
||
: """
|
||
INSERT INTO users(uid, name, email, avatar_url, color, updated_at) VALUES ($u, $n, $e, $a, $c, $now)
|
||
ON CONFLICT(uid) DO UPDATE SET name = coalesce(excluded.name, name), email = coalesce(excluded.email, email),
|
||
avatar_url = excluded.avatar_url, color = excluded.color, updated_at = excluded.updated_at
|
||
""";
|
||
cmd.Parameters.AddWithValue("$u", uid);
|
||
cmd.Parameters.AddWithValue("$n", (object?)name ?? DBNull.Value);
|
||
cmd.Parameters.AddWithValue("$e", (object?)email ?? DBNull.Value);
|
||
cmd.Parameters.AddWithValue("$a", (object?)avatar ?? DBNull.Value);
|
||
cmd.Parameters.AddWithValue("$c", (object?)SafeColor(color) ?? DBNull.Value);
|
||
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"));
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
public static Person? Load(SqliteConnection c, string? uid)
|
||
{
|
||
if (string.IsNullOrEmpty(uid)) return null; // автора нет — поле пустое
|
||
using var cmd = c.CreateCommand();
|
||
cmd.CommandText = "SELECT name, email, avatar_url, color FROM users WHERE uid = $u";
|
||
cmd.Parameters.AddWithValue("$u", uid);
|
||
using var r = cmd.ExecuteReader();
|
||
if (!r.Read()) return new Person(uid, null, null, null, null);
|
||
string? S(int i) => r.IsDBNull(i) ? null : r.GetString(i);
|
||
return new Person(uid, S(0), S(1), S(2), S(3));
|
||
}
|
||
|
||
/// <summary>Только https-URL или небольшая data:image (CRM без облака) — то, что можно отдать в <img src>.</summary>
|
||
private static string? Avatar(string? v)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(v)) return null;
|
||
v = v.Trim();
|
||
if (v.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && v.Length <= 2048) return v;
|
||
if (v.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase) && v.Length <= 400_000) return v;
|
||
return null;
|
||
}
|
||
|
||
private static string? SafeColor(string? v) =>
|
||
v is not null && System.Text.RegularExpressions.Regex.IsMatch(v, "^#[0-9A-Fa-f]{3,8}$") ? v : null;
|
||
|
||
private static string? Clean(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||
|
||
private static string? Str(JsonElement e, string p) =>
|
||
e.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||
}
|