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:
Rafał Tyński
2026-10-04 03:49:45 +02:00
parent f4f3b3896a
commit fa1199539b
136 changed files with 2338 additions and 574 deletions
@@ -0,0 +1,113 @@
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Matchmaking;
namespace TarninaHub.Infrastructure.Assist;
/// <summary>
/// Middleman Innowacji (F-8). Adapts an innovation to a service form for an
/// institution. Uses the configured LLM when available and a deterministic,
/// template-based rationale otherwise.
/// </summary>
public sealed class MiddlemanAssistant : IMiddlemanService
{
private const string SystemPrompt =
"Jesteś Middlemanem Innowacji w Małopolskim Hubie Innowacji Społecznych. " +
"Dostosowujesz sprawdzoną innowację do formy usługi dla instytucji (JST/NGO). " +
"Odpowiadaj prostym językiem, konkretnie (co/komu/jak) i nie wymyślaj faktów.";
private readonly IInnovationService _innovations;
private readonly ILlmProvider _llmProvider;
private readonly ILlmRequestLimiter _llmRequestLimiter;
public MiddlemanAssistant(
IInnovationService innovations,
ILlmProvider llmProvider,
ILlmRequestLimiter llmRequestLimiter)
{
_innovations = innovations;
_llmProvider = llmProvider;
_llmRequestLimiter = llmRequestLimiter;
}
public async Task<MiddlemanResponse?> AdaptAsync(
MiddlemanRequest request,
CancellationToken cancellationToken)
{
var innovation = await _innovations.GetAsync(request.InnovationId, cancellationToken);
if (innovation is null)
{
return null;
}
var serviceForm = BuildServiceForm(innovation, request);
var rationale = BuildRationale(innovation, request);
var fromLlm = false;
if (_llmProvider.IsConfigured && _llmRequestLimiter.TryAcquire())
{
var profile = request.InstitutionProfile;
var userPrompt =
$"Innowacja: {innovation.Title}\n" +
$"Streszczenie: {innovation.Summary}\n" +
$"Opis: {innovation.Description}\n" +
$"Etap: {innovation.Stage}\n" +
$"Instytucja: {profile?.Type ?? "nieokreślona"} (obszar: {profile?.Area ?? "ogólny"})\n" +
$"Odbiorcy: {(profile?.Audiences is { Count: > 0 } audiences ? string.Join(", ", audiences) : "nieokreśleni")}\n" +
$"Potrzeby: {request.Needs ?? "ogólne"}\n" +
"Zaproponuj formę usługi (co/komu/jak) i uzasadnienie.";
var completion = await _llmProvider.CompleteAsync(SystemPrompt, userPrompt, cancellationToken);
if (!string.IsNullOrWhiteSpace(completion))
{
serviceForm = completion.Trim();
fromLlm = true;
}
}
return new MiddlemanResponse(serviceForm, BuildResources(innovation), rationale, fromLlm);
}
private static string BuildServiceForm(InnovationResponse innovation, MiddlemanRequest request)
{
var audiences = request.InstitutionProfile?.Audiences is { Count: > 0 } values
? string.Join(", ", values)
: "odbiorcy wskazani przez instytucję";
var area = request.InstitutionProfile?.Area ?? innovation.ChallengeId;
return $"Co: {innovation.Title} — {innovation.Summary}. " +
$"Komu: {audiences}. " +
$"Jak: wdrożyć jako usługę w obszarze '{area}', etap '{innovation.Stage}', " +
$"z potrzeby: {request.Needs ?? "zdiagnozowanej lokalnie"}.";
}
private static string BuildRationale(InnovationResponse innovation, MiddlemanRequest request)
{
var institution = request.InstitutionProfile?.Type ?? "instytucja";
return $"Innowacja na etapie '{innovation.Stage}' odpowiada profilowi '{institution}' i obszarowi " +
$"'{request.InstitutionProfile?.Area ?? innovation.ChallengeId}', dlatego nadaje się do " +
"przekształcenia w powtarzalną usługę społeczną.";
}
private static IReadOnlyList<string> BuildResources(InnovationResponse innovation)
{
var resources = new List<string>
{
"Koordynator usługi i zespół realizacyjny",
"Budżet na pilotaż oraz pomiar efektów",
};
if (!string.IsNullOrWhiteSpace(innovation.Cost))
{
resources.Add($"Szacowany koszt: {innovation.Cost}");
}
resources.Add("Partnerzy lokalni (NGO/JST/firma) do wdrożenia");
return resources;
}
}
@@ -85,6 +85,12 @@ public static class DependencyInjection
services.AddSingleton<IGrantCallService, GrantCallService>();
services.AddSingleton<IApplicationService, ApplicationService>();
services.AddSingleton<IIdeaAssistant, IdeaAssistant>();
services.AddSingleton<INotificationService, NotificationService>();
services.AddSingleton<IThreadService, ThreadService>();
services.AddSingleton<ITestFeedbackService, TestFeedbackService>();
services.AddSingleton<IAdminService, AdminService>();
services.AddSingleton<IMiddlemanService, MiddlemanAssistant>();
services.AddSingleton<IPartnershipService, PartnershipService>();
return services;
}
@@ -0,0 +1,96 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class AdminService : IAdminService
{
private const int MaxSubmissions = 200;
private readonly ISurrealDbClient _client;
private readonly ISubmissionService _submissions;
private readonly IKnowledgeItemService _knowledgeItems;
public AdminService(
ISurrealDbClient client,
ISubmissionService submissions,
IKnowledgeItemService knowledgeItems)
{
_client = client;
_submissions = submissions;
_knowledgeItems = knowledgeItems;
}
public async Task<IReadOnlyList<SubmissionResponse>> ListSubmissionsAsync(
string? status,
string? challengeId,
CancellationToken cancellationToken)
{
var page = await _submissions.ListAsync(
viewerId: string.Empty,
isStaff: true,
status,
page: 1,
pageSize: MaxSubmissions,
cancellationToken);
return string.IsNullOrWhiteSpace(challengeId)
? page.Items
: page.Items.Where(item => item.ChallengeId == challengeId).ToList();
}
public async Task<KnowledgeItemResponse?> VerifyKnowledgeItemAsync(
string knowledgeItemId,
VerifyKnowledgeItemRequest request,
CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(knowledgeItemId);
var response = await _client.RawQuery(
"UPDATE type::record($table, $id) SET published = $published, updated_at = time::now();",
new Dictionary<string, object?>
{
["table"] = table,
["id"] = id,
["published"] = request.Verified,
},
cancellationToken);
response.EnsureAllOks();
return await _knowledgeItems.GetAsync(knowledgeItemId, cancellationToken);
}
public async Task<AdminMetricsResponse> GetMetricsAsync(CancellationToken cancellationToken)
{
var response = await _client.RawQuery(
"SELECT count() AS count FROM submission GROUP ALL; " +
"SELECT count() AS count FROM problem GROUP ALL; " +
"SELECT count() AS count FROM idea GROUP ALL; " +
"SELECT count() AS count FROM innovation GROUP ALL; " +
"SELECT count() AS count FROM knowledge_item GROUP ALL; " +
"SELECT count() AS count FROM user GROUP ALL; " +
"SELECT count() AS count FROM grant_call WHERE status = 'open' GROUP ALL; " +
"SELECT count() AS count FROM application GROUP ALL;",
new Dictionary<string, object?>(),
cancellationToken);
response.EnsureAllOks();
return new AdminMetricsResponse(
Count(response, 0),
Count(response, 1),
Count(response, 2),
Count(response, 3),
Count(response, 4),
Count(response, 5),
Count(response, 6),
Count(response, 7));
}
private static int Count(SurrealDb.Net.Models.Response.SurrealDbResponse response, int index)
{
var rows = response.GetValue<List<CountRow>>(index) ?? [];
return rows.Count > 0 ? rows[0].Count : 0;
}
}
@@ -0,0 +1,19 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class ApplicationRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("grant_call")] public string GrantCall { get; set; } = string.Empty;
[CborProperty("idea")] public string Idea { get; set; } = string.Empty;
[CborProperty("content")] public Dictionary<string, string?> Content { get; set; } = [];
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
}
@@ -66,7 +66,7 @@ public sealed class ApplicationService : IApplicationService
["grantId"] = grantId,
["ideaTable"] = ideaTable,
["ideaId"] = ideaIdPart,
["content"] = ToStorageContent(request.Content),
["content"] = request.Content ?? new Dictionary<string, string?>(),
},
cancellationToken);
response.EnsureAllOks();
@@ -113,24 +113,7 @@ public sealed class ApplicationService : IApplicationService
row.Id,
row.GrantCall,
row.Idea,
row.Content.ToDictionary(pair => pair.Key, pair => (object?)pair.Value),
row.Content,
row.Status,
row.CreatedAt);
private static Dictionary<string, string?> ToStorageContent(IReadOnlyDictionary<string, object?>? content)
{
var result = new Dictionary<string, string?>();
if (content is null)
{
return result;
}
foreach (var (key, value) in content)
{
result[key] = value?.ToString();
}
return result;
}
}
@@ -16,7 +16,7 @@ public sealed class AuditLogService : IAuditLogService
string? actorId,
string action,
string target,
IReadOnlyDictionary<string, object?> context,
IReadOnlyDictionary<string, string?> context,
CancellationToken cancellationToken)
{
var (actorTable, actorIdPart) = actorId is null
@@ -0,0 +1,15 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class ChallengeRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("name")] public string Name { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("order")] public int Order { get; set; }
}
@@ -0,0 +1,19 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class ConsentRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("user")] public string User { get; set; } = string.Empty;
[CborProperty("scope")] public string Scope { get; set; } = string.Empty;
[CborProperty("granted_at")] public DateTimeOffset GrantedAt { get; set; }
[CborProperty("expires_at")] public DateTimeOffset? ExpiresAt { get; set; }
[CborProperty("revoked_at")] public DateTimeOffset? RevokedAt { get; set; }
}
@@ -0,0 +1,9 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class CountRow
{
[CborProperty("count")] public int Count { get; set; }
}
@@ -0,0 +1,19 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class GrantCallRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("name")] public string Name { get; set; } = string.Empty;
[CborProperty("opens_at")] public DateTimeOffset OpensAt { get; set; }
[CborProperty("closes_at")] public DateTimeOffset ClosesAt { get; set; }
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("external_id")] public string? ExternalId { get; set; }
}
@@ -55,6 +55,30 @@ public sealed class GrantCallService : IGrantCallService
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<GrantCallResponse?> UpdateStatusAsync(
string grantCallId,
string status,
CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(grantCallId);
var response = await _client.RawQuery(
"UPDATE type::record($table, $id) SET status = $status; " +
$"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?>
{
["table"] = table,
["id"] = id,
["status"] = status,
},
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<GrantCallRow>>(1) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
private static GrantCallResponse Map(GrantCallRow row) => new(
row.Id,
row.Name,
@@ -0,0 +1,9 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class IdRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
}
@@ -0,0 +1,17 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class IdeaCardRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("idea")] public string Idea { get; set; } = string.Empty;
[CborProperty("essence")] public string Essence { get; set; } = string.Empty;
[CborProperty("audience")] public string Audience { get; set; } = string.Empty;
[CborProperty("stage")] public string Stage { get; set; } = string.Empty;
}
@@ -0,0 +1,19 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class IdeaRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("author")] public string Author { get; set; } = string.Empty;
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
}
@@ -93,6 +93,19 @@ public sealed class IdeaService : IIdeaService
return result;
}
public async Task<IReadOnlyList<string>> ListDistinctAuthorIdsAsync(CancellationToken cancellationToken)
{
var response = await _client.RawQuery(
"SELECT type::string(author) AS id FROM idea;",
new Dictionary<string, object?>(),
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<IdRow>>(0) ?? [];
return rows.Select(row => row.Id).Distinct(StringComparer.Ordinal).ToList();
}
public async Task<IdeaResponse?> UpdateAsync(
string ideaId,
UpdateIdeaRequest request,
@@ -0,0 +1,31 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class InnovationRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("summary")] public string Summary { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("stage")] public string Stage { get; set; } = string.Empty;
[CborProperty("cost")] public string? Cost { get; set; }
[CborProperty("contact")] public Dictionary<string, string?>? Contact { get; set; }
[CborProperty("media")] public List<Dictionary<string, string?>> Media { get; set; } = [];
[CborProperty("published")] public bool Published { get; set; }
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
[CborProperty("embedding")] public List<float>? Embedding { get; set; }
}
@@ -68,7 +68,7 @@ public sealed class InnovationService : IInnovationService
["challengeTable"] = challengeTable,
["challengeId"] = challengeId,
["stage"] = request.Stage,
["media"] = request.Media ?? [],
["media"] = ToConcreteList(request.Media),
["published"] = request.Published,
};
@@ -112,7 +112,7 @@ public sealed class InnovationService : IInnovationService
["challengeTable"] = challengeTable,
["challengeId"] = challengeId,
["stage"] = request.Stage,
["media"] = request.Media ?? [],
["media"] = ToConcreteList(request.Media),
["published"] = request.Published,
};
@@ -200,13 +200,13 @@ public sealed class InnovationService : IInnovationService
List<string> assignments,
Dictionary<string, object?> parameters,
string field,
IReadOnlyDictionary<string, object?>? value,
IReadOnlyDictionary<string, string?>? value,
bool clearWhenMissing = false)
{
if (value is not null)
{
assignments.Add($"{field} = $field_{field}");
parameters[$"field_{field}"] = value;
parameters[$"field_{field}"] = value.ToDictionary(pair => pair.Key, pair => pair.Value);
}
else if (clearWhenMissing)
{
@@ -214,6 +214,10 @@ public sealed class InnovationService : IInnovationService
}
}
private static List<Dictionary<string, string?>> ToConcreteList(
IReadOnlyList<IReadOnlyDictionary<string, string?>>? values) =>
values?.Select(item => item.ToDictionary(pair => pair.Key, pair => pair.Value)).ToList() ?? [];
private static InnovationResponse Map(InnovationRow row) => new(
row.Id,
row.Title,
@@ -222,8 +226,8 @@ public sealed class InnovationService : IInnovationService
row.Challenge,
row.Stage,
row.Cost,
row.Contact is null ? null : row.Contact,
row.Media.Select(item => (IReadOnlyDictionary<string, object?>)item).ToList(),
row.Contact,
row.Media.Select(item => (IReadOnlyDictionary<string, string?>)item).ToList(),
row.Published,
row.UpdatedAt);
}
@@ -0,0 +1,23 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class KnowledgeItemRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("kind")] public string Kind { get; set; } = string.Empty;
[CborProperty("challenge")] public string? Challenge { get; set; }
[CborProperty("source_url")] public string? SourceUrl { get; set; }
[CborProperty("attachments")] public List<Dictionary<string, string?>> Attachments { get; set; } = [];
[CborProperty("published")] public bool Published { get; set; }
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
}
@@ -62,7 +62,7 @@ public sealed class KnowledgeItemService : IKnowledgeItemService
["id"] = id,
["title"] = request.Title,
["kind"] = request.Kind,
["attachments"] = request.Attachments ?? [],
["attachments"] = ToConcreteList(request.Attachments),
["published"] = request.Published,
};
@@ -99,7 +99,7 @@ public sealed class KnowledgeItemService : IKnowledgeItemService
["id"] = id,
["title"] = request.Title,
["kind"] = request.Kind,
["attachments"] = request.Attachments ?? [],
["attachments"] = ToConcreteList(request.Attachments),
["published"] = request.Published,
};
@@ -216,13 +216,17 @@ public sealed class KnowledgeItemService : IKnowledgeItemService
}
}
private static List<Dictionary<string, string?>> ToConcreteList(
IReadOnlyList<IReadOnlyDictionary<string, string?>>? values) =>
values?.Select(item => item.ToDictionary(pair => pair.Key, pair => pair.Value)).ToList() ?? [];
private static KnowledgeItemResponse Map(KnowledgeItemRow row) => new(
row.Id,
row.Title,
row.Kind,
NormalizeRecordId(row.Challenge),
row.SourceUrl,
row.Attachments.Select(item => (IReadOnlyDictionary<string, object?>)item).ToList(),
row.Attachments.Select(item => (IReadOnlyDictionary<string, string?>)item).ToList(),
row.Published,
row.UpdatedAt);
@@ -0,0 +1,17 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class MessageRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("thread")] public string Thread { get; set; } = string.Empty;
[CborProperty("author")] public string Author { get; set; } = string.Empty;
[CborProperty("body")] public string Body { get; set; } = string.Empty;
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,17 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class NotificationRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("type")] public string Type { get; set; } = string.Empty;
[CborProperty("payload")] public Dictionary<string, string?> Payload { get; set; } = [];
[CborProperty("read_at")] public DateTimeOffset? ReadAt { get; set; }
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,115 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class NotificationService : INotificationService
{
private const string SelectFields =
"type::string(id) AS id, type, payload, read_at, created_at";
private static readonly string[] StaffRoles = ["operator", "manager", "admin"];
private readonly ISurrealDbClient _client;
public NotificationService(ISurrealDbClient client)
{
_client = client;
}
public async Task NotifyStaffAsync(
string type,
IReadOnlyDictionary<string, string?> payload,
CancellationToken cancellationToken)
{
var response = await _client.RawQuery(
"SELECT type::string(id) AS id FROM user WHERE role IN $roles;",
new Dictionary<string, object?> { ["roles"] = StaffRoles },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<UserRow>>(0) ?? [];
foreach (var row in rows)
{
await NotifyUserAsync(row.Id, type, payload, cancellationToken);
}
}
public async Task NotifyUserAsync(
string userId,
string type,
IReadOnlyDictionary<string, string?> payload,
CancellationToken cancellationToken)
{
var notificationId = Guid.NewGuid().ToString("N");
var (table, id) = SurrealRecordId.Split(userId);
var response = await _client.RawQuery(
"CREATE type::record('notification', $id) SET recipient = type::record($table, $recipientId), " +
"type = $type, payload = $payload;",
new Dictionary<string, object?>
{
["id"] = notificationId,
["table"] = table,
["recipientId"] = id,
["type"] = type,
["payload"] = payload,
},
cancellationToken);
response.EnsureAllOks();
}
public async Task<IReadOnlyList<NotificationResponse>> ListForUserAsync(
string userId,
CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(userId);
var response = await _client.RawQuery(
$"SELECT {SelectFields} FROM notification WHERE recipient = type::record($table, $id) " +
"ORDER BY created_at DESC;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<NotificationRow>>(0) ?? [];
return rows.Select(Map).ToList();
}
public async Task<NotificationResponse?> MarkReadAsync(
string notificationId,
string userId,
CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(notificationId);
var (userTable, userIdPart) = SurrealRecordId.Split(userId);
var response = await _client.RawQuery(
"UPDATE type::record($table, $id) SET read_at = time::now() " +
"WHERE recipient = type::record($userTable, $userId); " +
$"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?>
{
["table"] = table,
["id"] = id,
["userTable"] = userTable,
["userId"] = userIdPart,
},
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<NotificationRow>>(1) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
private static NotificationResponse Map(NotificationRow row) => new(
row.Id,
row.Type,
row.Payload,
row.ReadAt,
row.CreatedAt);
}
@@ -0,0 +1,17 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class PartnershipRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("initiator")] public string Initiator { get; set; } = string.Empty;
[CborProperty("subject")] public string Subject { get; set; } = string.Empty;
[CborProperty("partners")] public List<string> Partners { get; set; } = [];
[CborProperty("status")] public string Status { get; set; } = string.Empty;
}
@@ -0,0 +1,95 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class PartnershipService : IPartnershipService
{
private const string SelectFields =
"type::string(id) AS id, type::string(initiator) AS initiator, subject, " +
"array::map(partners, |$p| type::string($p)) AS partners, status";
private readonly ISurrealDbClient _client;
public PartnershipService(ISurrealDbClient client)
{
_client = client;
}
public async Task<IReadOnlyList<PartnershipResponse>> 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 initiator = type::record($table, $id)";
parameters["table"] = table;
parameters["id"] = id;
}
var response = await _client.RawQuery(
$"SELECT {SelectFields} FROM partnership{where} ORDER BY subject ASC;",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<PartnershipRow>>(0) ?? [];
return rows.Select(Map).ToList();
}
public async Task<PartnershipResponse> CreateAsync(
string initiatorId,
CreatePartnershipRequest request,
CancellationToken cancellationToken)
{
var partnershipId = Guid.NewGuid().ToString("N");
var (initiatorTable, initiatorIdPart) = SurrealRecordId.Split(initiatorId);
var partners = request.Partners ?? [];
var parameters = new Dictionary<string, object?>
{
["id"] = partnershipId,
["subject"] = request.Subject,
["initiatorTable"] = initiatorTable,
["initiatorId"] = initiatorIdPart,
};
var partnerExpressions = new List<string>(partners.Count);
for (var i = 0; i < partners.Count; i++)
{
var (table, id) = SurrealRecordId.Split(partners[i]);
parameters[$"table{i}"] = table;
parameters[$"id{i}"] = id;
partnerExpressions.Add($"type::record($table{i}, $id{i})");
}
var response = await _client.RawQuery(
"CREATE type::record('partnership', $id) SET " +
"initiator = type::record($initiatorTable, $initiatorId), subject = $subject, " +
$"partners = [{string.Join(", ", partnerExpressions)}], status = 'proposed'; " +
$"SELECT {SelectFields} FROM type::record('partnership', $id) LIMIT 1;",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<PartnershipRow>>(1) ?? [];
return rows.Count > 0
? Map(rows[0])
: throw new InvalidOperationException("Partnership could not be loaded after creation.");
}
private static PartnershipResponse Map(PartnershipRow row) => new(
row.Id,
row.Initiator,
row.Subject,
row.Partners,
row.Status);
}
@@ -0,0 +1,9 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class PasswordCheckRow
{
[CborProperty("password_ok")] public bool PasswordOk { get; set; }
}
@@ -0,0 +1,21 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class ProblemRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("location")] public string Location { get; set; } = string.Empty;
[CborProperty("author")] public string Author { get; set; } = string.Empty;
[CborProperty("embedding")] public List<float>? Embedding { get; set; }
}
@@ -0,0 +1,29 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class SubmissionRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("problem")] public string Problem { get; set; } = string.Empty;
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("keywords")] public List<string> Keywords { get; set; } = [];
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("location")] public string Location { get; set; } = string.Empty;
[CborProperty("author")] public string Author { get; set; } = string.Empty;
}
@@ -1,215 +0,0 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class UserRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("phone")] public string Phone { get; set; } = string.Empty;
[CborProperty("email")] public string? Email { get; set; }
[CborProperty("role")] public string Role { get; set; } = string.Empty;
[CborProperty("organization")] public string? Organization { get; set; }
}
internal sealed class PasswordCheckRow
{
[CborProperty("password_ok")] public bool PasswordOk { get; set; }
}
internal sealed class ConsentRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("user")] public string User { get; set; } = string.Empty;
[CborProperty("scope")] public string Scope { get; set; } = string.Empty;
[CborProperty("granted_at")] public DateTimeOffset GrantedAt { get; set; }
[CborProperty("expires_at")] public DateTimeOffset? ExpiresAt { get; set; }
[CborProperty("revoked_at")] public DateTimeOffset? RevokedAt { get; set; }
}
internal sealed class SubmissionRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("problem")] public string Problem { get; set; } = string.Empty;
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("keywords")] public List<string> Keywords { get; set; } = [];
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("location")] public string Location { get; set; } = string.Empty;
[CborProperty("author")] public string Author { get; set; } = string.Empty;
}
internal sealed class CountRow
{
[CborProperty("count")] public int Count { get; set; }
}
internal sealed class ChallengeRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("name")] public string Name { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("order")] public int Order { get; set; }
}
internal sealed class KnowledgeItemRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("kind")] public string Kind { get; set; } = string.Empty;
[CborProperty("challenge")] public string? Challenge { get; set; }
[CborProperty("source_url")] public string? SourceUrl { get; set; }
[CborProperty("attachments")] public List<Dictionary<string, object?>> Attachments { get; set; } = [];
[CborProperty("published")] public bool Published { get; set; }
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
}
internal sealed class InnovationRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("summary")] public string Summary { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("stage")] public string Stage { get; set; } = string.Empty;
[CborProperty("cost")] public string? Cost { get; set; }
[CborProperty("contact")] public Dictionary<string, object?>? Contact { get; set; }
[CborProperty("media")] public List<Dictionary<string, object?>> Media { get; set; } = [];
[CborProperty("published")] public bool Published { get; set; }
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
[CborProperty("embedding")] public List<float>? Embedding { get; set; }
}
internal sealed class ProblemRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("location")] public string Location { get; set; } = string.Empty;
[CborProperty("author")] public string Author { get; set; } = string.Empty;
[CborProperty("embedding")] public List<float>? Embedding { get; set; }
}
internal sealed class TrendRow
{
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("count")] public int Count { get; set; }
}
internal sealed class TrendSubmissionRow
{
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
}
internal sealed class IdRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
}
internal sealed class IdeaRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("author")] public string Author { get; set; } = string.Empty;
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
}
internal sealed class IdeaCardRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("idea")] public string Idea { get; set; } = string.Empty;
[CborProperty("essence")] public string Essence { get; set; } = string.Empty;
[CborProperty("audience")] public string Audience { get; set; } = string.Empty;
[CborProperty("stage")] public string Stage { get; set; } = string.Empty;
}
internal sealed class GrantCallRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("name")] public string Name { get; set; } = string.Empty;
[CborProperty("opens_at")] public DateTimeOffset OpensAt { get; set; }
[CborProperty("closes_at")] public DateTimeOffset ClosesAt { get; set; }
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("external_id")] public string? ExternalId { get; set; }
}
internal sealed class ApplicationRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("grant_call")] public string GrantCall { get; set; } = string.Empty;
[CborProperty("idea")] public string Idea { get; set; } = string.Empty;
[CborProperty("content")] public Dictionary<string, string?> Content { get; set; } = [];
[CborProperty("status")] public string Status { get; set; } = string.Empty;
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,21 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class TestFeedbackRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("innovation")] public string Innovation { get; set; } = string.Empty;
[CborProperty("author")] public string Author { get; set; } = string.Empty;
[CborProperty("rating")] public int Rating { get; set; }
[CborProperty("comment")] public string Comment { get; set; } = string.Empty;
[CborProperty("improvements")] public string Improvements { get; set; } = string.Empty;
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,90 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class TestFeedbackService : ITestFeedbackService
{
private const string SelectFields =
"type::string(id) AS id, type::string(innovation) AS innovation, type::string(author) AS author, " +
"rating, comment, improvements, created_at";
private readonly ISurrealDbClient _client;
private readonly IInnovationService _innovations;
public TestFeedbackService(ISurrealDbClient client, IInnovationService innovations)
{
_client = client;
_innovations = innovations;
}
public async Task<TestFeedbackResponse?> CreateAsync(
string innovationId,
string authorId,
CreateTestFeedbackRequest request,
CancellationToken cancellationToken)
{
var innovation = await _innovations.GetAsync(innovationId, cancellationToken);
if (innovation is null)
{
return null;
}
var feedbackId = Guid.NewGuid().ToString("N");
var (innovationTable, innovationIdPart) = SurrealRecordId.Split(innovationId);
var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId);
var response = await _client.RawQuery(
"CREATE type::record('test_feedback', $id) SET " +
"innovation = type::record($innovationTable, $innovationId), " +
"author = type::record($authorTable, $authorId), rating = $rating, " +
"comment = $comment, improvements = $improvements; " +
$"SELECT {SelectFields} FROM type::record('test_feedback', $id) LIMIT 1;",
new Dictionary<string, object?>
{
["id"] = feedbackId,
["innovationTable"] = innovationTable,
["innovationId"] = innovationIdPart,
["authorTable"] = authorTable,
["authorId"] = authorIdPart,
["rating"] = request.Rating,
["comment"] = request.Comment ?? string.Empty,
["improvements"] = request.Improvements ?? string.Empty,
},
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<TestFeedbackRow>>(1) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<IReadOnlyList<TestFeedbackResponse>> ListForInnovationAsync(
string innovationId,
CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(innovationId);
var response = await _client.RawQuery(
$"SELECT {SelectFields} FROM test_feedback WHERE innovation = type::record($table, $id) " +
"ORDER BY created_at DESC;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<TestFeedbackRow>>(0) ?? [];
return rows.Select(Map).ToList();
}
private static TestFeedbackResponse Map(TestFeedbackRow row) => new(
row.Id,
row.Innovation,
row.Author,
row.Rating,
row.Comment,
row.Improvements,
row.CreatedAt);
}
@@ -0,0 +1,15 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class ThreadRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("subject")] public string Subject { get; set; } = string.Empty;
[CborProperty("participants")] public List<string> Participants { get; set; } = [];
[CborProperty("created_at")] public DateTimeOffset CreatedAt { get; set; }
}
@@ -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);
}
@@ -0,0 +1,11 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class TrendRow
{
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("count")] public int Count { get; set; }
}
@@ -0,0 +1,9 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class TrendSubmissionRow
{
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
}
@@ -0,0 +1,17 @@
using Dahomey.Cbor.Attributes;
namespace TarninaHub.Infrastructure.Persistence;
internal sealed class UserRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("phone")] public string Phone { get; set; } = string.Empty;
[CborProperty("email")] public string? Email { get; set; }
[CborProperty("role")] public string Role { get; set; } = string.Empty;
[CborProperty("organization")] public string? Organization { get; set; }
}