Implement the idea creator and application generator: idea status workflow, social canvas card, grant call windows with 409 when closed, and an LLM-backed idea assistant with offline fallback.
219 lines
7.6 KiB
C#
219 lines
7.6 KiB
C#
using SurrealDb.Net;
|
|
using TarninaHub.Application.Abstractions;
|
|
using TarninaHub.Application.Dtos;
|
|
using TarninaHub.Application.Mappings;
|
|
|
|
namespace TarninaHub.Infrastructure.Persistence;
|
|
|
|
public sealed class IdeaService : IIdeaService
|
|
{
|
|
private const string SelectIdeaFields =
|
|
"type::string(id) AS id, title, description, type::string(author) AS author, status, updated_at";
|
|
|
|
private const string SelectCardFields =
|
|
"type::string(id) AS id, type::string(idea) AS idea, essence, audience, stage";
|
|
|
|
private readonly ISurrealDbClient _client;
|
|
|
|
public IdeaService(ISurrealDbClient client)
|
|
{
|
|
_client = client;
|
|
}
|
|
|
|
public async Task<IdeaResponse> CreateAsync(
|
|
string authorId,
|
|
CreateIdeaRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var ideaId = Guid.NewGuid().ToString("N");
|
|
var cardId = Guid.NewGuid().ToString("N");
|
|
var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId);
|
|
var card = request.Card ?? new IdeaCardRequest(null, null, null);
|
|
|
|
var ideaResponse = await _client.RawQuery(
|
|
"CREATE type::record('idea', $id) SET title = $title, description = $description, " +
|
|
"author = type::record($authorTable, $authorId), status = 'draft';",
|
|
new Dictionary<string, object?>
|
|
{
|
|
["id"] = ideaId,
|
|
["title"] = request.Title,
|
|
["description"] = request.Description,
|
|
["authorTable"] = authorTable,
|
|
["authorId"] = authorIdPart,
|
|
},
|
|
cancellationToken);
|
|
ideaResponse.EnsureAllOks();
|
|
|
|
var cardResponse = await _client.RawQuery(
|
|
"CREATE type::record('idea_card', $id) SET idea = type::record('idea', $ideaId), " +
|
|
"essence = $essence, audience = $audience, stage = $stage;",
|
|
new Dictionary<string, object?>
|
|
{
|
|
["id"] = cardId,
|
|
["ideaId"] = ideaId,
|
|
["essence"] = card.Essence ?? string.Empty,
|
|
["audience"] = card.Audience ?? string.Empty,
|
|
["stage"] = string.IsNullOrWhiteSpace(card.Stage) ? "idea" : card.Stage,
|
|
},
|
|
cancellationToken);
|
|
cardResponse.EnsureAllOks();
|
|
|
|
return await LoadAsync($"idea:{ideaId}", cancellationToken)
|
|
?? throw new InvalidOperationException("Idea could not be loaded after creation.");
|
|
}
|
|
|
|
public async Task<IdeaResponse?> GetAsync(string ideaId, CancellationToken cancellationToken) =>
|
|
await LoadAsync(ideaId, cancellationToken);
|
|
|
|
public async Task<IReadOnlyList<IdeaResponse>> ListForAuthorAsync(
|
|
string authorId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var (authorTable, authorIdPart) = SurrealRecordId.Split(authorId);
|
|
|
|
var response = await _client.RawQuery(
|
|
$"SELECT {SelectIdeaFields} FROM idea WHERE author = type::record($authorTable, $authorId) " +
|
|
"ORDER BY updated_at DESC;",
|
|
new Dictionary<string, object?>
|
|
{
|
|
["authorTable"] = authorTable,
|
|
["authorId"] = authorIdPart,
|
|
},
|
|
cancellationToken);
|
|
response.EnsureAllOks();
|
|
|
|
var rows = response.GetValue<List<IdeaRow>>(0) ?? [];
|
|
var result = new List<IdeaResponse>(rows.Count);
|
|
|
|
foreach (var row in rows)
|
|
{
|
|
result.Add(Map(row, await LoadCardAsync(row.Id, cancellationToken)));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<IdeaResponse?> UpdateAsync(
|
|
string ideaId,
|
|
UpdateIdeaRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var (table, id) = SurrealRecordId.Split(ideaId);
|
|
var parameters = new Dictionary<string, object?>
|
|
{
|
|
["table"] = table,
|
|
["id"] = id,
|
|
["title"] = request.Title,
|
|
["description"] = request.Description,
|
|
};
|
|
|
|
var assignments = new List<string>
|
|
{
|
|
"title = $title", "description = $description", "updated_at = time::now()",
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.Status) && IdeaStatusWire.Parse(request.Status) is { } status)
|
|
{
|
|
assignments.Add("status = $status");
|
|
parameters["status"] = status.ToWire();
|
|
}
|
|
|
|
var response = await _client.RawQuery(
|
|
$"UPDATE type::record($table, $id) SET {string.Join(", ", assignments)}; " +
|
|
$"SELECT {SelectIdeaFields} FROM type::record($table, $id) LIMIT 1;",
|
|
parameters,
|
|
cancellationToken);
|
|
response.EnsureAllOks();
|
|
|
|
var rows = response.GetValue<List<IdeaRow>>(1) ?? [];
|
|
|
|
if (rows.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return Map(rows[0], await LoadCardAsync(ideaId, cancellationToken));
|
|
}
|
|
|
|
public async Task<IdeaCardResponse?> GetCardAsync(string ideaId, CancellationToken cancellationToken) =>
|
|
await LoadCardAsync(ideaId, cancellationToken);
|
|
|
|
public async Task<IdeaCardResponse?> UpdateCardAsync(
|
|
string ideaId,
|
|
IdeaCardRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var (table, id) = SurrealRecordId.Split(ideaId);
|
|
|
|
var response = await _client.RawQuery(
|
|
"UPDATE idea_card SET essence = $essence, audience = $audience, stage = $stage " +
|
|
"WHERE idea = type::record($table, $id); " +
|
|
$"SELECT {SelectCardFields} FROM idea_card WHERE idea = type::record($table, $id) LIMIT 1;",
|
|
new Dictionary<string, object?>
|
|
{
|
|
["table"] = table,
|
|
["id"] = id,
|
|
["essence"] = request.Essence ?? string.Empty,
|
|
["audience"] = request.Audience ?? string.Empty,
|
|
["stage"] = string.IsNullOrWhiteSpace(request.Stage) ? "idea" : request.Stage,
|
|
},
|
|
cancellationToken);
|
|
response.EnsureAllOks();
|
|
|
|
var rows = response.GetValue<List<IdeaCardRow>>(1) ?? [];
|
|
|
|
return rows.Count > 0 ? MapCard(rows[0]) : null;
|
|
}
|
|
|
|
private async Task<IdeaResponse?> LoadAsync(string ideaId, CancellationToken cancellationToken)
|
|
{
|
|
var (table, id) = SurrealRecordId.Split(ideaId);
|
|
|
|
var response = await _client.RawQuery(
|
|
$"SELECT {SelectIdeaFields} FROM type::record($table, $id) LIMIT 1;",
|
|
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
|
|
cancellationToken);
|
|
response.EnsureAllOks();
|
|
|
|
var rows = response.GetValue<List<IdeaRow>>(0) ?? [];
|
|
|
|
if (rows.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return Map(rows[0], await LoadCardAsync(ideaId, cancellationToken));
|
|
}
|
|
|
|
private async Task<IdeaCardResponse?> LoadCardAsync(string ideaId, CancellationToken cancellationToken)
|
|
{
|
|
var (table, id) = SurrealRecordId.Split(ideaId);
|
|
|
|
var response = await _client.RawQuery(
|
|
$"SELECT {SelectCardFields} FROM idea_card WHERE idea = type::record($table, $id) LIMIT 1;",
|
|
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
|
|
cancellationToken);
|
|
response.EnsureAllOks();
|
|
|
|
var rows = response.GetValue<List<IdeaCardRow>>(0) ?? [];
|
|
|
|
return rows.Count > 0 ? MapCard(rows[0]) : null;
|
|
}
|
|
|
|
private static IdeaResponse Map(IdeaRow row, IdeaCardResponse? card) => new(
|
|
row.Id,
|
|
row.Title,
|
|
row.Description,
|
|
row.Author,
|
|
row.Status,
|
|
card,
|
|
row.UpdatedAt);
|
|
|
|
private static IdeaCardResponse MapCard(IdeaCardRow row) => new(
|
|
row.Id,
|
|
row.Idea,
|
|
row.Essence,
|
|
row.Audience,
|
|
row.Stage);
|
|
}
|