Add knowledge item and innovation CRUD with search/filtering, published-only visibility for non-staff, admin publish/update, challenge dictionary, problem aggregation with trends (admin only) and canonical embedding text preparation for matchmaking.
230 lines
7.8 KiB
C#
230 lines
7.8 KiB
C#
using SurrealDb.Net;
|
|
using TarninaHub.Application.Abstractions;
|
|
using TarninaHub.Application.Dtos;
|
|
|
|
namespace TarninaHub.Infrastructure.Persistence;
|
|
|
|
public sealed class InnovationService : IInnovationService
|
|
{
|
|
private const string SelectFields =
|
|
"type::string(id) AS id, title, summary, description, type::string(challenge) AS challenge, " +
|
|
"stage, cost, contact, media, published, updated_at";
|
|
|
|
private readonly ISurrealDbClient _client;
|
|
|
|
public InnovationService(ISurrealDbClient client)
|
|
{
|
|
_client = client;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<InnovationResponse>> ListAsync(
|
|
string? challengeId,
|
|
string? stage,
|
|
string? query,
|
|
bool onlyPublished,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var where = BuildFilter(challengeId, stage, query, onlyPublished, out var parameters);
|
|
|
|
var response = await _client.RawQuery(
|
|
$"SELECT {SelectFields} FROM innovation{where} ORDER BY updated_at DESC;",
|
|
parameters,
|
|
cancellationToken);
|
|
response.EnsureAllOks();
|
|
|
|
var rows = response.GetValue<List<InnovationRow>>(0) ?? [];
|
|
|
|
return rows.Select(Map).ToList();
|
|
}
|
|
|
|
public async Task<InnovationResponse?> GetAsync(string innovationId, CancellationToken cancellationToken)
|
|
{
|
|
var (table, id) = SurrealRecordId.Split(innovationId);
|
|
|
|
var response = await _client.RawQuery(
|
|
$"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;",
|
|
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
|
|
cancellationToken);
|
|
response.EnsureAllOks();
|
|
|
|
var rows = response.GetValue<List<InnovationRow>>(0) ?? [];
|
|
|
|
return rows.Count > 0 ? Map(rows[0]) : null;
|
|
}
|
|
|
|
public async Task<InnovationResponse> CreateAsync(
|
|
InnovationRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var id = Guid.NewGuid().ToString("N");
|
|
var (challengeTable, challengeId) = SurrealRecordId.Split(request.ChallengeId);
|
|
|
|
var parameters = new Dictionary<string, object?>
|
|
{
|
|
["id"] = id,
|
|
["title"] = request.Title,
|
|
["summary"] = request.Summary ?? string.Empty,
|
|
["description"] = request.Description ?? string.Empty,
|
|
["challengeTable"] = challengeTable,
|
|
["challengeId"] = challengeId,
|
|
["stage"] = request.Stage,
|
|
["media"] = request.Media ?? [],
|
|
["published"] = request.Published,
|
|
};
|
|
|
|
var assignments = new List<string>
|
|
{
|
|
"title = $title", "summary = $summary", "description = $description",
|
|
"challenge = type::record($challengeTable, $challengeId)", "stage = $stage",
|
|
"media = $media", "published = $published", "updated_at = time::now()",
|
|
};
|
|
|
|
AppendOptional(assignments, parameters, "cost", request.Cost);
|
|
AppendOptionalObject(assignments, parameters, "contact", request.Contact);
|
|
|
|
var response = await _client.RawQuery(
|
|
"CREATE type::record('innovation', $id) SET " + string.Join(", ", assignments) + "; " +
|
|
$"SELECT {SelectFields} FROM type::record('innovation', $id) LIMIT 1;",
|
|
parameters,
|
|
cancellationToken);
|
|
response.EnsureAllOks();
|
|
|
|
var rows = response.GetValue<List<InnovationRow>>(1) ?? [];
|
|
|
|
return Map(rows[0]);
|
|
}
|
|
|
|
public async Task<InnovationResponse?> UpdateAsync(
|
|
string innovationId,
|
|
InnovationRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var (table, id) = SurrealRecordId.Split(innovationId);
|
|
var (challengeTable, challengeId) = SurrealRecordId.Split(request.ChallengeId);
|
|
|
|
var parameters = new Dictionary<string, object?>
|
|
{
|
|
["table"] = table,
|
|
["id"] = id,
|
|
["title"] = request.Title,
|
|
["summary"] = request.Summary ?? string.Empty,
|
|
["description"] = request.Description ?? string.Empty,
|
|
["challengeTable"] = challengeTable,
|
|
["challengeId"] = challengeId,
|
|
["stage"] = request.Stage,
|
|
["media"] = request.Media ?? [],
|
|
["published"] = request.Published,
|
|
};
|
|
|
|
var assignments = new List<string>
|
|
{
|
|
"title = $title", "summary = $summary", "description = $description",
|
|
"challenge = type::record($challengeTable, $challengeId)", "stage = $stage",
|
|
"media = $media", "published = $published", "updated_at = time::now()",
|
|
};
|
|
|
|
AppendOptional(assignments, parameters, "cost", request.Cost, clearWhenMissing: true);
|
|
AppendOptionalObject(assignments, parameters, "contact", request.Contact, clearWhenMissing: true);
|
|
|
|
var response = await _client.RawQuery(
|
|
$"UPDATE type::record($table, $id) SET {string.Join(", ", assignments)}; " +
|
|
$"SELECT {SelectFields} FROM type::record($table, $id) LIMIT 1;",
|
|
parameters,
|
|
cancellationToken);
|
|
response.EnsureAllOks();
|
|
|
|
var rows = response.GetValue<List<InnovationRow>>(1) ?? [];
|
|
|
|
return rows.Count > 0 ? Map(rows[0]) : null;
|
|
}
|
|
|
|
private static string BuildFilter(
|
|
string? challengeId,
|
|
string? stage,
|
|
string? query,
|
|
bool onlyPublished,
|
|
out Dictionary<string, object?> parameters)
|
|
{
|
|
parameters = new Dictionary<string, object?>();
|
|
var clauses = new List<string>();
|
|
|
|
if (!string.IsNullOrWhiteSpace(challengeId))
|
|
{
|
|
var (table, id) = SurrealRecordId.Split(challengeId);
|
|
clauses.Add("challenge = type::record($challengeTable, $challengeId)");
|
|
parameters["challengeTable"] = table;
|
|
parameters["challengeId"] = id;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(stage))
|
|
{
|
|
clauses.Add("stage = $stage");
|
|
parameters["stage"] = stage;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(query))
|
|
{
|
|
clauses.Add(
|
|
"(string::contains(string::lowercase(title), string::lowercase($query)) " +
|
|
"OR string::contains(string::lowercase(summary), string::lowercase($query)))");
|
|
parameters["query"] = query;
|
|
}
|
|
|
|
if (onlyPublished)
|
|
{
|
|
clauses.Add("published = true");
|
|
}
|
|
|
|
return clauses.Count > 0 ? " WHERE " + string.Join(" AND ", clauses) : string.Empty;
|
|
}
|
|
|
|
private static void AppendOptional(
|
|
List<string> assignments,
|
|
Dictionary<string, object?> parameters,
|
|
string field,
|
|
string? value,
|
|
bool clearWhenMissing = false)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
assignments.Add($"{field} = $field_{field}");
|
|
parameters[$"field_{field}"] = value;
|
|
}
|
|
else if (clearWhenMissing)
|
|
{
|
|
assignments.Add($"{field} = NONE");
|
|
}
|
|
}
|
|
|
|
private static void AppendOptionalObject(
|
|
List<string> assignments,
|
|
Dictionary<string, object?> parameters,
|
|
string field,
|
|
IReadOnlyDictionary<string, object?>? value,
|
|
bool clearWhenMissing = false)
|
|
{
|
|
if (value is not null)
|
|
{
|
|
assignments.Add($"{field} = $field_{field}");
|
|
parameters[$"field_{field}"] = value;
|
|
}
|
|
else if (clearWhenMissing)
|
|
{
|
|
assignments.Add($"{field} = NONE");
|
|
}
|
|
}
|
|
|
|
private static InnovationResponse Map(InnovationRow row) => new(
|
|
row.Id,
|
|
row.Title,
|
|
row.Summary,
|
|
row.Description,
|
|
row.Challenge,
|
|
row.Stage,
|
|
row.Cost,
|
|
row.Contact is null ? null : row.Contact,
|
|
row.Media.Select(item => (IReadOnlyDictionary<string, object?>)item).ToList(),
|
|
row.Published,
|
|
row.UpdatedAt);
|
|
}
|