using SurrealDb.Net; using TarninaHub.Application.Abstractions; using TarninaHub.Application.Dtos; namespace TarninaHub.Infrastructure.Persistence; public sealed class ThreadService : IThreadService { private const string SelectThreadFields = "type::string(id) AS id, subject, array::map(participants, |$p| type::string($p)) AS participants, created_at"; private const string SelectMessageFields = "type::string(id) AS id, type::string(thread) AS thread, type::string(author) AS author, body, created_at"; private readonly ISurrealDbClient _client; public ThreadService(ISurrealDbClient client) { _client = client; } public async Task> ListForUserAsync( string userId, bool isStaff, CancellationToken cancellationToken) { var parameters = new Dictionary(); var where = string.Empty; if (!isStaff) { var (table, id) = SurrealRecordId.Split(userId); where = " WHERE participants CONTAINS type::record($table, $id)"; parameters["table"] = table; parameters["id"] = id; } var response = await _client.RawQuery( $"SELECT {SelectThreadFields} FROM thread{where} ORDER BY created_at DESC;", parameters, cancellationToken); response.EnsureAllOks(); var rows = response.GetValue>(0) ?? []; return rows.Select(row => Map(row, [])).ToList(); } public async Task GetAsync( string threadId, string userId, bool isStaff, CancellationToken cancellationToken) { var thread = await LoadThreadAsync(threadId, cancellationToken); if (thread is null) { return null; } if (!isStaff && !thread.Participants.Contains(userId)) { return null; } var messages = await LoadMessagesAsync(threadId, cancellationToken); return Map(thread, messages); } public async Task CreateAsync( string creatorId, CreateThreadRequest request, CancellationToken cancellationToken) { var threadId = Guid.NewGuid().ToString("N"); var participants = new List { creatorId }; foreach (var participant in request.Participants ?? []) { if (!string.IsNullOrWhiteSpace(participant) && !participants.Contains(participant)) { participants.Add(participant); } } var parameters = new Dictionary { ["id"] = threadId, ["subject"] = request.Subject, }; var recordExpressions = new List(participants.Count); for (var i = 0; i < participants.Count; i++) { var (table, id) = SurrealRecordId.Split(participants[i]); parameters[$"table{i}"] = table; parameters[$"id{i}"] = id; recordExpressions.Add($"type::record($table{i}, $id{i})"); } var response = await _client.RawQuery( $"CREATE type::record('thread', $id) SET subject = $subject, " + $"participants = [{string.Join(", ", recordExpressions)}]; " + $"SELECT {SelectThreadFields} FROM type::record('thread', $id) LIMIT 1;", parameters, cancellationToken); response.EnsureAllOks(); var rows = response.GetValue>(1) ?? []; return rows.Count > 0 ? Map(rows[0], []) : throw new InvalidOperationException("Thread could not be loaded after creation."); } public async Task AddMessageAsync( string threadId, string authorId, bool isStaff, CreateMessageRequest request, CancellationToken cancellationToken) { var thread = await LoadThreadAsync(threadId, cancellationToken); if (thread is null || (!isStaff && !thread.Participants.Contains(authorId))) { return null; } var messageId = Guid.NewGuid().ToString("N"); var (threadTable, threadIdPart) = SurrealRecordId.Split(threadId); var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId); var response = await _client.RawQuery( "CREATE type::record('message', $id) SET thread = type::record($threadTable, $threadId), " + "author = type::record($authorTable, $authorId), body = $body; " + $"SELECT {SelectMessageFields} FROM type::record('message', $id) LIMIT 1;", new Dictionary { ["id"] = messageId, ["threadTable"] = threadTable, ["threadId"] = threadIdPart, ["authorTable"] = authorTable, ["authorId"] = authorIdPart, ["body"] = request.Body, }, cancellationToken); response.EnsureAllOks(); var rows = response.GetValue>(1) ?? []; return rows.Count > 0 ? MapMessage(rows[0]) : null; } private async Task LoadThreadAsync(string threadId, CancellationToken cancellationToken) { var (table, id) = SurrealRecordId.Split(threadId); var response = await _client.RawQuery( $"SELECT {SelectThreadFields} FROM type::record($table, $id) LIMIT 1;", new Dictionary { ["table"] = table, ["id"] = id }, cancellationToken); response.EnsureAllOks(); var rows = response.GetValue>(0) ?? []; return rows.Count > 0 ? rows[0] : null; } private async Task> LoadMessagesAsync( string threadId, CancellationToken cancellationToken) { var (table, id) = SurrealRecordId.Split(threadId); var response = await _client.RawQuery( $"SELECT {SelectMessageFields} FROM message WHERE thread = type::record($table, $id) " + "ORDER BY created_at ASC;", new Dictionary { ["table"] = table, ["id"] = id }, cancellationToken); response.EnsureAllOks(); var rows = response.GetValue>(0) ?? []; return rows.Select(MapMessage).ToList(); } private static ThreadResponse Map(ThreadRow row, IReadOnlyList messages) => new( row.Id, row.Subject, row.Participants, row.CreatedAt, messages); private static MessageResponse MapMessage(MessageRow row) => new( row.Id, row.Thread, row.Author, row.Body, row.CreatedAt); }