Files
crm-wiki/backend/Wiki.Api/Profiles.cs

145 lines
8.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/&lt;id&gt;).
/// Запрос идёт с токеном пользователя (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 без облака) — то, что можно отдать в &lt;img src&gt;.</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;
}