feat(backend): add communication, tester, admin and middleman modules
Implement step 17 modules: threads/messages, notifications with automation (submission.created, idea.created, grant_call.opened), innovation test feedback, admin panel (submissions, verify, metrics, grant call status), Middleman adaptation and partnerships. Split multi-type files into one type per file and replace object-valued dictionaries with string-valued maps in data contracts (migrations 0006/0007).
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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<IReadOnlyList<ThreadResponse>> ListForUserAsync(
|
||||
string userId,
|
||||
bool isStaff,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parameters = new Dictionary<string, object?>();
|
||||
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<List<ThreadRow>>(0) ?? [];
|
||||
|
||||
return rows.Select(row => Map(row, [])).ToList();
|
||||
}
|
||||
|
||||
public async Task<ThreadResponse?> 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<ThreadResponse> CreateAsync(
|
||||
string creatorId,
|
||||
CreateThreadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var threadId = Guid.NewGuid().ToString("N");
|
||||
var participants = new List<string> { creatorId };
|
||||
|
||||
foreach (var participant in request.Participants ?? [])
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(participant) && !participants.Contains(participant))
|
||||
{
|
||||
participants.Add(participant);
|
||||
}
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = threadId,
|
||||
["subject"] = request.Subject,
|
||||
};
|
||||
|
||||
var recordExpressions = new List<string>(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<List<ThreadRow>>(1) ?? [];
|
||||
|
||||
return rows.Count > 0
|
||||
? Map(rows[0], [])
|
||||
: throw new InvalidOperationException("Thread could not be loaded after creation.");
|
||||
}
|
||||
|
||||
public async Task<MessageResponse?> 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<string, object?>
|
||||
{
|
||||
["id"] = messageId,
|
||||
["threadTable"] = threadTable,
|
||||
["threadId"] = threadIdPart,
|
||||
["authorTable"] = authorTable,
|
||||
["authorId"] = authorIdPart,
|
||||
["body"] = request.Body,
|
||||
},
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<MessageRow>>(1) ?? [];
|
||||
|
||||
return rows.Count > 0 ? MapMessage(rows[0]) : null;
|
||||
}
|
||||
|
||||
private async Task<ThreadRow?> 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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<ThreadRow>>(0) ?? [];
|
||||
|
||||
return rows.Count > 0 ? rows[0] : null;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<MessageResponse>> 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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<MessageRow>>(0) ?? [];
|
||||
|
||||
return rows.Select(MapMessage).ToList();
|
||||
}
|
||||
|
||||
private static ThreadResponse Map(ThreadRow row, IReadOnlyList<MessageResponse> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user