mirror of
https://github.com/claunia/marechai.git
synced 2026-07-08 17:57:08 +00:00
- Introduced SoftwareVersionSuggestionMetadata class to handle metadata for software version suggestions, including fields for version string, public version, codename, parent version ID, and license ID. - Updated SuggestionMetadataRegistry to register the new SoftwareVersionSuggestionMetadata. - Enhanced localization files for Spanish, French, Italian, and Dutch with new entries related to software version suggestions, including prompts for suggesting new versions, required fields, and error messages.
5482 lines
290 KiB
C#
5482 lines
290 KiB
C#
/******************************************************************************
|
|
// MARECHAI: Master repository of computing history artifacts information
|
|
// ----------------------------------------------------------------------------
|
|
//
|
|
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
|
//
|
|
// --[ License ] --------------------------------------------------------------
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
//
|
|
// ----------------------------------------------------------------------------
|
|
// Copyright © 2003-2026 Natalia Portillo
|
|
*******************************************************************************/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using Marechai.Data;
|
|
using Marechai.Data.Dtos;
|
|
using Marechai.Database.Models;
|
|
using Marechai.Server.Helpers;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
namespace Marechai.Server.Controllers;
|
|
|
|
/// <summary>
|
|
/// Endpoints for the user-driven collaborative-suggestions workflow:
|
|
/// <list type="bullet">
|
|
/// <item><c>POST /suggestions</c> — any authenticated user creates a suggestion.</item>
|
|
/// <item><c>GET /suggestions/queue</c> — admins list pending (UberAdmin: optional history).</item>
|
|
/// <item><c>GET /suggestions/{id}/diff</c> — admins fetch a live diff of one suggestion.</item>
|
|
/// <item><c>POST /suggestions/{id}/review</c> — admins accept/reject per-field.</item>
|
|
/// <item><c>GET /auth/me/suggestions</c> — caller lists their own suggestions.</item>
|
|
/// <item><c>DELETE /auth/me/suggestions/{id}</c> — caller withdraws their pending suggestion.</item>
|
|
/// </list>
|
|
/// Per-entity dispatch lives in the <c>switch</c> inside <see cref="ApplyAcceptedFieldsAsync" />.
|
|
/// Phase-1 only wires Company; the other entity cases throw <c>NotImplementedException</c> by design.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Authorize]
|
|
public class SuggestionsController(MarechaiContext context,
|
|
UserManager<ApplicationUser> userManager,
|
|
IConfiguration configuration,
|
|
IHttpClientFactory httpClientFactory) : ControllerBase
|
|
{
|
|
public const string CollaboratorRole = "Collaborator";
|
|
public const string CuratorRole = "Curator";
|
|
public const string AdminRole = "Admin";
|
|
public const string UberAdminRole = "UberAdmin";
|
|
|
|
public const int MaxSuggestedFields = 50;
|
|
public const int MaxUserCommentLength = 2000;
|
|
public const int RateWindowDayLimit = 10; // 10 suggestions per 24h
|
|
public const int RateWindowBurstLimit = 3; // 3 in any 60-second window
|
|
|
|
static readonly TimeSpan s_rateDayWindow = TimeSpan.FromHours(24);
|
|
static readonly TimeSpan s_rateBurstWindow = TimeSpan.FromMinutes(1);
|
|
|
|
/// <summary>
|
|
/// Asset root path for media uploads. Captured from configuration so the per-entity
|
|
/// applier dispatch (<see cref="ApplyAcceptedFieldsAsync" />) can pass it to entities
|
|
/// that promote pending uploads (e.g. Book covers via
|
|
/// <see cref="Marechai.Server.Helpers.PendingImageStore.PromoteToOriginalsAsync" />).
|
|
/// </summary>
|
|
readonly string _assetRootPath = configuration["AssetRootPath"]!;
|
|
|
|
static readonly JsonSerializerOptions s_json = new()
|
|
{
|
|
WriteIndented = false,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
/// <summary>
|
|
/// Entity types for which a brand-new-entity suggestion (<c>EntityId == null</c>) is
|
|
/// accepted. Edit suggestions (<c>EntityId.HasValue</c>) are still gated by the per-entity
|
|
/// applier dispatch below.
|
|
/// </summary>
|
|
static readonly HashSet<SuggestionEntityType> s_supportsAddition = new()
|
|
{
|
|
SuggestionEntityType.Company,
|
|
SuggestionEntityType.Machine,
|
|
SuggestionEntityType.Magazine,
|
|
SuggestionEntityType.Book,
|
|
SuggestionEntityType.Document,
|
|
SuggestionEntityType.MagazineIssue,
|
|
SuggestionEntityType.Gpu,
|
|
SuggestionEntityType.Processor,
|
|
SuggestionEntityType.SoundSynth,
|
|
SuggestionEntityType.Person,
|
|
SuggestionEntityType.Software,
|
|
SuggestionEntityType.SoftwareRelease,
|
|
SuggestionEntityType.SoftwareVersion
|
|
};
|
|
|
|
// ───────────────────────────── POST /suggestions ─────────────────────────────
|
|
|
|
[HttpPost("/suggestions")]
|
|
[ProducesResponseType(typeof(SuggestionDto), StatusCodes.Status201Created)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
|
|
public async Task<ActionResult<SuggestionDto>> CreateAsync([FromBody] SuggestionDto dto)
|
|
{
|
|
string userId = User.FindFirstValue(ClaimTypes.Sid);
|
|
if(string.IsNullOrEmpty(userId)) return Unauthorized();
|
|
if(dto is null) return BadRequest();
|
|
|
|
// EntityId == null signals a brand-new-entity suggestion. Only certain entity types
|
|
// allow that today (see s_supportsAddition); edits still require a positive EntityId.
|
|
bool isAddition = !dto.EntityId.HasValue || dto.EntityId.Value <= 0;
|
|
|
|
if(isAddition && !s_supportsAddition.Contains(dto.EntityType))
|
|
return Problem(title: "Invalid suggestion",
|
|
detail:
|
|
$"Suggesting a brand-new {dto.EntityType} is not supported. Provide an EntityId to suggest edits instead.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
Dictionary<string, object> values;
|
|
|
|
try
|
|
{
|
|
values = SuggestionsHelper.DeserializeValues(dto.SuggestedValuesJson);
|
|
}
|
|
catch(JsonException)
|
|
{
|
|
return Problem(title: "Invalid suggestion",
|
|
detail: "SuggestedValuesJson is not a valid JSON object.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
// ---- Payload size guard -----------------------------------------------------
|
|
if(values.Count == 0)
|
|
return Problem(title: "Empty suggestion",
|
|
detail: "At least one field must be suggested for change.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(values.Count > MaxSuggestedFields)
|
|
return Problem(title: "Suggestion too large",
|
|
detail:
|
|
$"A suggestion cannot include more than {MaxSuggestedFields} fields at once.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!string.IsNullOrEmpty(dto.UserComment) && dto.UserComment.Length > MaxUserCommentLength)
|
|
return Problem(title: "Comment too long",
|
|
detail: $"User comment cannot exceed {MaxUserCommentLength} characters.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
// ---- Field-name validation: only known field names are accepted -------------
|
|
if(!IsEntityTypeSupported(dto.EntityType))
|
|
return Problem(title: "Unsupported entity",
|
|
detail: $"Suggestions for {dto.EntityType} are not yet supported.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
List<string> unknown = values.Keys.Where(k => !IsKnownFieldName(dto.EntityType, k)).ToList();
|
|
|
|
if(unknown.Count > 0)
|
|
return Problem(title: "Invalid suggestion",
|
|
detail: $"Unknown field name(s): {string.Join(", ", unknown)}",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
// ---- Per-entity-type Subkey + value validation ------------------------------
|
|
// CompanyDescription / MachineDescription require a Subkey (ISO-639-3 language code) + a non-empty markdown.
|
|
string subkey = string.IsNullOrWhiteSpace(dto.Subkey) ? null : dto.Subkey.Trim();
|
|
|
|
if(dto.EntityType == SuggestionEntityType.CompanyDescription)
|
|
{
|
|
if(subkey is null ||
|
|
!Suggestions.CompanyDescriptionSuggestionApplier.IsAllowedLanguage(subkey))
|
|
return Problem(title: "Invalid language",
|
|
detail:
|
|
"Description suggestions must specify a supported ISO-639-3 language code (eng, spa, deu, fra, ita, lat, por).",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!values.TryGetValue(Suggestions.CompanyDescriptionSuggestionApplier.FieldMarkdown,
|
|
out object mdValue) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(mdValue)))
|
|
return Problem(title: "Empty description",
|
|
detail:
|
|
"Description cannot be empty. Use the admin delete flow to remove a description.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
else if(dto.EntityType == SuggestionEntityType.MachineDescription)
|
|
{
|
|
if(subkey is null ||
|
|
!Suggestions.MachineDescriptionSuggestionApplier.IsAllowedLanguage(subkey))
|
|
return Problem(title: "Invalid language",
|
|
detail:
|
|
"Description suggestions must specify a supported ISO-639-3 language code (eng, spa, deu, fra, ita, lat, por).",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!values.TryGetValue(Suggestions.MachineDescriptionSuggestionApplier.FieldMarkdown,
|
|
out object mdValue) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(mdValue)))
|
|
return Problem(title: "Empty description",
|
|
detail:
|
|
"Description cannot be empty. Use the admin delete flow to remove a description.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
else if(dto.EntityType == SuggestionEntityType.BookSynopsis)
|
|
{
|
|
if(subkey is null ||
|
|
!Suggestions.BookSynopsisSuggestionApplier.IsAllowedLanguage(subkey))
|
|
return Problem(title: "Invalid language",
|
|
detail:
|
|
"Synopsis suggestions must specify a supported ISO-639-3 language code (eng, spa, deu, fra, ita, lat, por).",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!values.TryGetValue(Suggestions.BookSynopsisSuggestionApplier.FieldMarkdown,
|
|
out object mdValue) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(mdValue)))
|
|
return Problem(title: "Empty synopsis",
|
|
detail:
|
|
"Synopsis cannot be empty. Use the admin delete flow to remove a synopsis.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
else if(dto.EntityType == SuggestionEntityType.DocumentSynopsis)
|
|
{
|
|
if(subkey is null ||
|
|
!Suggestions.DocumentSynopsisSuggestionApplier.IsAllowedLanguage(subkey))
|
|
return Problem(title: "Invalid language",
|
|
detail:
|
|
"Synopsis suggestions must specify a supported ISO-639-3 language code (eng, spa, deu, fra, ita, lat, por).",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!values.TryGetValue(Suggestions.DocumentSynopsisSuggestionApplier.FieldMarkdown,
|
|
out object mdValue) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(mdValue)))
|
|
return Problem(title: "Empty synopsis",
|
|
detail:
|
|
"Synopsis cannot be empty. Use the admin delete flow to remove a synopsis.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
else if(dto.EntityType == SuggestionEntityType.MagazineSynopsis)
|
|
{
|
|
if(subkey is null ||
|
|
!Suggestions.MagazineSynopsisSuggestionApplier.IsAllowedLanguage(subkey))
|
|
return Problem(title: "Invalid language",
|
|
detail:
|
|
"Synopsis suggestions must specify a supported ISO-639-3 language code (eng, spa, deu, fra, ita, lat, por).",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!values.TryGetValue(Suggestions.MagazineSynopsisSuggestionApplier.FieldMarkdown,
|
|
out object mdValue) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(mdValue)))
|
|
return Problem(title: "Empty synopsis",
|
|
detail:
|
|
"Synopsis cannot be empty. Use the admin delete flow to remove a synopsis.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
else if(dto.EntityType == SuggestionEntityType.GpuDescription)
|
|
{
|
|
if(subkey is null ||
|
|
!Suggestions.GpuDescriptionSuggestionApplier.IsAllowedLanguage(subkey))
|
|
return Problem(title: "Invalid language",
|
|
detail:
|
|
"Description suggestions must specify a supported ISO-639-3 language code (eng, spa, deu, fra, ita, lat, por).",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!values.TryGetValue(Suggestions.GpuDescriptionSuggestionApplier.FieldMarkdown,
|
|
out object mdValue) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(mdValue)))
|
|
return Problem(title: "Empty description",
|
|
detail:
|
|
"Description cannot be empty. Use the admin delete flow to remove a description.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
else if(dto.EntityType == SuggestionEntityType.ProcessorDescription)
|
|
{
|
|
if(subkey is null ||
|
|
!Suggestions.ProcessorDescriptionSuggestionApplier.IsAllowedLanguage(subkey))
|
|
return Problem(title: "Invalid language",
|
|
detail:
|
|
"Description suggestions must specify a supported ISO-639-3 language code (eng, spa, deu, fra, ita, lat, por).",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!values.TryGetValue(Suggestions.ProcessorDescriptionSuggestionApplier.FieldMarkdown,
|
|
out object mdValue) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(mdValue)))
|
|
return Problem(title: "Empty description",
|
|
detail:
|
|
"Description cannot be empty. Use the admin delete flow to remove a description.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
else if(dto.EntityType == SuggestionEntityType.SoundSynthDescription)
|
|
{
|
|
if(subkey is null ||
|
|
!Suggestions.SoundSynthDescriptionSuggestionApplier.IsAllowedLanguage(subkey))
|
|
return Problem(title: "Invalid language",
|
|
detail:
|
|
"Description suggestions must specify a supported ISO-639-3 language code (eng, spa, deu, fra, ita, lat, por).",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!values.TryGetValue(Suggestions.SoundSynthDescriptionSuggestionApplier.FieldMarkdown,
|
|
out object mdValue) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(mdValue)))
|
|
return Problem(title: "Empty description",
|
|
detail:
|
|
"Description cannot be empty. Use the admin delete flow to remove a description.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
else if(dto.EntityType == SuggestionEntityType.PersonDescription)
|
|
{
|
|
if(subkey is null ||
|
|
!Suggestions.PersonDescriptionSuggestionApplier.IsAllowedLanguage(subkey))
|
|
return Problem(title: "Invalid language",
|
|
detail:
|
|
"Biography suggestions must specify a supported ISO-639-3 language code (eng, spa, deu, fra, ita, lat, por).",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!values.TryGetValue(Suggestions.PersonDescriptionSuggestionApplier.FieldMarkdown,
|
|
out object mdValue) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(mdValue)))
|
|
return Problem(title: "Empty biography",
|
|
detail:
|
|
"Biography cannot be empty. Use the admin delete flow to remove a biography.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
else if(dto.EntityType == SuggestionEntityType.SoftwareDescription)
|
|
{
|
|
if(subkey is null ||
|
|
!Suggestions.SoftwareDescriptionSuggestionApplier.IsAllowedLanguage(subkey))
|
|
return Problem(title: "Invalid language",
|
|
detail:
|
|
"Description suggestions must specify a supported ISO-639-3 language code (eng, spa, deu, fra, ita, lat, por).",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
if(!values.TryGetValue(Suggestions.SoftwareDescriptionSuggestionApplier.FieldMarkdown,
|
|
out object mdValue) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(mdValue)))
|
|
return Problem(title: "Empty description",
|
|
detail:
|
|
"Description cannot be empty. Use the admin delete flow to remove a description.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
else
|
|
{
|
|
// Subkey is only meaningful for *Description / *Synopsis entity types today and
|
|
// for SoftwareRelease (which uses subkey to discriminate which card the user is
|
|
// editing — `specs`, `ratings`, or null for the general-info dialog — so the
|
|
// per-(user, entity, subkey) duplicate-pending guard treats those three flows
|
|
// as independent). Reject stray values for other entity types so the dedupe
|
|
// index doesn't get polluted with random strings.
|
|
if(subkey is not null)
|
|
{
|
|
bool subkeyAllowed = dto.EntityType == SuggestionEntityType.SoftwareRelease &&
|
|
(subkey == Suggestions.SoftwareReleaseSuggestionApplier.GroupSpecs ||
|
|
subkey == Suggestions.SoftwareReleaseSuggestionApplier.GroupRatings);
|
|
if(!subkeyAllowed)
|
|
return Problem(title: "Invalid suggestion",
|
|
detail:
|
|
$"Subkey is not supported for {dto.EntityType} suggestions.",
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
}
|
|
|
|
// ---- GPU photo batch payload validation (suggestion-level + per-photo sidecars) ----
|
|
if(dto.EntityType == SuggestionEntityType.GpuPhoto)
|
|
{
|
|
var (ok, err) = await Suggestions.GpuPhotoSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, _assetRootPath, userId);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid GPU photo suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
// ---- Processor photo batch payload validation (suggestion-level + per-photo sidecars) ----
|
|
if(dto.EntityType == SuggestionEntityType.ProcessorPhoto)
|
|
{
|
|
var (ok, err) = await Suggestions.ProcessorPhotoSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, _assetRootPath, userId);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid processor photo suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
// ---- SoundSynth photo batch payload validation (suggestion-level + per-photo sidecars) ----
|
|
if(dto.EntityType == SuggestionEntityType.SoundSynthPhoto)
|
|
{
|
|
var (ok, err) = await Suggestions.SoundSynthPhotoSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, _assetRootPath, userId);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid sound synth photo suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
// ---- Machine photo batch payload validation (Computer/Console/Smartphone share the same enum) ----
|
|
if(dto.EntityType == SuggestionEntityType.MachinePhoto)
|
|
{
|
|
var (ok, err) = await Suggestions.MachinePhotoSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, _assetRootPath, userId);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid machine photo suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
// ---- Software promo art batch payload validation ------------------------------
|
|
if(dto.EntityType == SuggestionEntityType.SoftwarePromoArt)
|
|
{
|
|
var (ok, err) = await Suggestions.SoftwarePromoArtSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, _assetRootPath, userId);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid software promo art suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
// ---- Software cover batch payload validation ----------------------------------
|
|
if(dto.EntityType == SuggestionEntityType.SoftwareCover)
|
|
{
|
|
var (ok, err) = await Suggestions.SoftwareCoverSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, _assetRootPath, userId);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid software cover suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
// ---- Software screenshot batch payload validation ----------------------------
|
|
if(dto.EntityType == SuggestionEntityType.SoftwareScreenshot)
|
|
{
|
|
var (ok, err) = await Suggestions.SoftwareScreenshotSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, _assetRootPath, userId);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid software screenshot suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
// ---- GPU video link payload validation (calls YouTube oEmbed for canonical title) ----
|
|
if(dto.EntityType == SuggestionEntityType.GpuVideo)
|
|
{
|
|
var (ok, err, vid, fetchedTitle) = await Suggestions.GpuVideoSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, httpClientFactory);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid GPU video suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
// Patch the canonical title into the persisted SuggestedValues so admins see it
|
|
// in the diff panel from the start, AND override the subkey to be the extracted
|
|
// video ID so the per-(user, entity, subkey) Pending dedupe gates resubmission
|
|
// of the same video while a previous suggestion is still pending.
|
|
values[Suggestions.GpuVideoSuggestionApplier.FieldTitle] = fetchedTitle;
|
|
subkey = vid;
|
|
}
|
|
|
|
// ---- Processor video link payload validation (calls YouTube oEmbed for canonical title) ----
|
|
if(dto.EntityType == SuggestionEntityType.ProcessorVideo)
|
|
{
|
|
var (ok, err, vid, fetchedTitle) = await Suggestions.ProcessorVideoSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, httpClientFactory);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid processor video suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
values[Suggestions.ProcessorVideoSuggestionApplier.FieldTitle] = fetchedTitle;
|
|
subkey = vid;
|
|
}
|
|
|
|
// ---- SoundSynth video link payload validation (calls YouTube oEmbed for canonical title) ----
|
|
if(dto.EntityType == SuggestionEntityType.SoundSynthVideo)
|
|
{
|
|
var (ok, err, vid, fetchedTitle) = await Suggestions.SoundSynthVideoSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, httpClientFactory);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid sound synth video suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
values[Suggestions.SoundSynthVideoSuggestionApplier.FieldTitle] = fetchedTitle;
|
|
subkey = vid;
|
|
}
|
|
|
|
// ---- Software video link payload validation (calls YouTube oEmbed for canonical title) ----
|
|
if(dto.EntityType == SuggestionEntityType.SoftwareVideo)
|
|
{
|
|
var (ok, err, vid, fetchedTitle) = await Suggestions.SoftwareVideoSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, httpClientFactory);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid software video suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
values[Suggestions.SoftwareVideoSuggestionApplier.FieldTitle] = fetchedTitle;
|
|
subkey = vid;
|
|
}
|
|
|
|
// ---- Machine video link payload validation (calls YouTube oEmbed for canonical title) ----
|
|
if(dto.EntityType == SuggestionEntityType.MachineVideo)
|
|
{
|
|
var (ok, err, vid, fetchedTitle) = await Suggestions.MachineVideoSuggestionApplier.ValidateAsync(
|
|
context, dto.EntityId, values, httpClientFactory);
|
|
|
|
if(!ok)
|
|
return Problem(title: "Invalid machine video suggestion",
|
|
detail: err,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
values[Suggestions.MachineVideoSuggestionApplier.FieldTitle] = fetchedTitle;
|
|
subkey = vid;
|
|
}
|
|
|
|
// ---- Existence check on target entity (edits only) --------------------------
|
|
// For additions, the entity does not exist yet; the display name is derived from the
|
|
// suggested 'name' payload field at projection time.
|
|
string entityDisplayName;
|
|
|
|
if(isAddition)
|
|
{
|
|
// Per-entity addition validation (e.g. Company requires a non-empty 'name').
|
|
string addError = ValidateAdditionPayload(dto.EntityType, values);
|
|
if(addError is not null)
|
|
return Problem(title: "Invalid suggestion",
|
|
detail: addError,
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
entityDisplayName = ExtractAdditionDisplayName(dto.EntityType, values);
|
|
|
|
// ---- Addition dedupe: by NAME (per-user pending + collision with existing) -----
|
|
string normalised = NormaliseName(entityDisplayName);
|
|
|
|
// Cross-check against existing entities of this type — duplicate entity names should
|
|
// be edited, not added a second time.
|
|
if(await EntityWithNameExistsAsync(dto.EntityType, normalised))
|
|
return Problem(title: "Already exists",
|
|
detail:
|
|
$"A {EntityLabel(dto.EntityType)} named '{entityDisplayName}' already exists. Suggest an edit on its page instead.",
|
|
statusCode: StatusCodes.Status409Conflict);
|
|
|
|
// Per-user pending dedupe by normalised name (in-memory match on the JSON payload).
|
|
// Project to ONLY the SuggestedValues column to keep the SELECT narrow — this avoids
|
|
// materialising columns that may not exist on the live database (defensive against
|
|
// unapplied migrations) and is also cheaper than fetching the full row.
|
|
List<Dictionary<string, object>> userPendingPayloads = await context.Suggestions
|
|
.Where(s => s.CreatedById == userId
|
|
&& s.EntityType == dto.EntityType
|
|
&& s.EntityId == null
|
|
&& s.Status == SuggestionStatus.Pending)
|
|
.Select(s => s.SuggestedValues)
|
|
.ToListAsync();
|
|
|
|
foreach(Dictionary<string, object> existingValues in userPendingPayloads)
|
|
{
|
|
string existingName = ExtractAdditionDisplayName(dto.EntityType, existingValues);
|
|
if(string.Equals(NormaliseName(existingName), normalised, StringComparison.Ordinal))
|
|
return Problem(title: "Duplicate pending suggestion",
|
|
detail:
|
|
$"You already have a pending suggestion to add a {EntityLabel(dto.EntityType)} named '{entityDisplayName}'.",
|
|
statusCode: StatusCodes.Status409Conflict);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
entityDisplayName = await GetEntityDisplayNameAsync(dto.EntityType, dto.EntityId.Value);
|
|
|
|
if(entityDisplayName is null)
|
|
return NotFound();
|
|
|
|
// ---- Per-entity dedupe: one Pending per (user, entity, subkey) --------------
|
|
// GpuPhoto, ProcessorPhoto, SoundSynthPhoto and MachinePhoto batches are intentionally
|
|
// exempt — a collaborator can have multiple pending photo batches for the same parent
|
|
// at once (the per-uploader cap of in-flight pending PHOTOS per parent is enforced
|
|
// separately by the upload endpoint via
|
|
// PendingImageStore.CountByUploaderForParentEntity).
|
|
if(dto.EntityType != SuggestionEntityType.GpuPhoto &&
|
|
dto.EntityType != SuggestionEntityType.ProcessorPhoto &&
|
|
dto.EntityType != SuggestionEntityType.SoundSynthPhoto &&
|
|
dto.EntityType != SuggestionEntityType.MachinePhoto &&
|
|
dto.EntityType != SuggestionEntityType.SoftwarePromoArt &&
|
|
dto.EntityType != SuggestionEntityType.SoftwareCover &&
|
|
dto.EntityType != SuggestionEntityType.SoftwareScreenshot)
|
|
{
|
|
bool dupe = await context.Suggestions.AnyAsync(s =>
|
|
s.CreatedById == userId
|
|
&& s.EntityType == dto.EntityType
|
|
&& s.EntityId == dto.EntityId.Value
|
|
&& s.Subkey == subkey
|
|
&& s.Status == SuggestionStatus.Pending);
|
|
|
|
if(dupe)
|
|
return Problem(title: "Duplicate pending suggestion",
|
|
detail:
|
|
"You already have a pending suggestion for this item. Please withdraw it or wait for it to be reviewed before submitting another.",
|
|
statusCode: StatusCodes.Status409Conflict);
|
|
}
|
|
}
|
|
|
|
// ---- Sliding-window rate caps (skipped for trusted roles) -------------------
|
|
bool trusted = await IsInAnyRoleAsync(userId,
|
|
CollaboratorRole, CuratorRole, AdminRole, UberAdminRole);
|
|
|
|
if(!trusted)
|
|
{
|
|
DateTime now = DateTime.UtcNow;
|
|
DateTime dayCutoff = now - s_rateDayWindow;
|
|
DateTime burstCutoff = now - s_rateBurstWindow;
|
|
|
|
int dayCount = await context.Suggestions.CountAsync(s =>
|
|
s.CreatedById == userId && s.CreatedOn >= dayCutoff);
|
|
|
|
if(dayCount >= RateWindowDayLimit)
|
|
{
|
|
Response.Headers["Retry-After"] = ((int)s_rateDayWindow.TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
|
return Problem(title: "Too many suggestions",
|
|
detail:
|
|
$"You can submit at most {RateWindowDayLimit} suggestions per 24 hours. Please wait before submitting another.",
|
|
statusCode: StatusCodes.Status429TooManyRequests);
|
|
}
|
|
|
|
int burstCount = await context.Suggestions.CountAsync(s =>
|
|
s.CreatedById == userId && s.CreatedOn >= burstCutoff);
|
|
|
|
if(burstCount >= RateWindowBurstLimit)
|
|
{
|
|
Response.Headers["Retry-After"] = ((int)s_rateBurstWindow.TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
|
return Problem(title: "Too many suggestions",
|
|
detail:
|
|
$"You can submit at most {RateWindowBurstLimit} suggestions per minute. Please slow down.",
|
|
statusCode: StatusCodes.Status429TooManyRequests);
|
|
}
|
|
}
|
|
|
|
// ---- Persist ----------------------------------------------------------------
|
|
var suggestion = new Suggestion
|
|
{
|
|
EntityType = dto.EntityType,
|
|
EntityId = isAddition ? null : dto.EntityId.Value,
|
|
Subkey = subkey,
|
|
Status = SuggestionStatus.Pending,
|
|
CreatedById = userId,
|
|
UserComment = string.IsNullOrWhiteSpace(dto.UserComment) ? null : dto.UserComment.Trim(),
|
|
SuggestedValues = values
|
|
};
|
|
|
|
context.Suggestions.Add(suggestion);
|
|
await context.SaveChangesAsync();
|
|
|
|
// ---- Notify all admins/uberadmins -------------------------------------------
|
|
ApplicationUser sender = await userManager.FindByIdAsync(userId);
|
|
string senderTag = sender?.UserName is null ? "a user" : "@" + sender.UserName;
|
|
string entityLabel = EntityLabel(dto.EntityType);
|
|
string secondaryLabel = await GetEntitySecondaryLabelAsync(dto.EntityType, subkey);
|
|
string entitySuffix = string.IsNullOrEmpty(secondaryLabel) ? string.Empty : " " + secondaryLabel;
|
|
|
|
string subject;
|
|
string body;
|
|
|
|
if(isAddition)
|
|
{
|
|
string nameTag = string.IsNullOrEmpty(entityDisplayName) ? string.Empty : $": **{entityDisplayName}**";
|
|
subject = $"New {entityLabel} suggested";
|
|
body = $"{senderTag} suggested a new {entityLabel}{nameTag}. " +
|
|
$"Open the [suggestions queue](/admin/suggestions-queue) to review.";
|
|
}
|
|
else
|
|
{
|
|
string entityLink = EntityLink(dto.EntityType, dto.EntityId.Value, entityDisplayName);
|
|
subject = $"New suggestion for {entityLabel} #{dto.EntityId.Value}";
|
|
body = $"{senderTag} suggested changes to {entityLabel} {entityLink}{entitySuffix}. " +
|
|
$"Open the [suggestions queue](/admin/suggestions-queue) to review.";
|
|
}
|
|
|
|
await MessageDispatcher.SendSystemMessageToAdminsAsync(context, userManager, subject, body);
|
|
|
|
SuggestionDto outDto = await ProjectAsync(suggestion, entityDisplayName);
|
|
return CreatedAtAction(null, new { id = suggestion.Id }, outDto);
|
|
}
|
|
|
|
// ───────────────────────────── GET /suggestions/queue ─────────────────────────────
|
|
|
|
[HttpGet("/suggestions/queue/count")]
|
|
[Authorize(Roles = AdminRole + "," + UberAdminRole)]
|
|
[ProducesResponseType(typeof(int), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
public async Task<ActionResult<int>> GetPendingCountAsync()
|
|
{
|
|
int count = await context.Suggestions.CountAsync(s => s.Status == SuggestionStatus.Pending);
|
|
return Ok(count);
|
|
}
|
|
|
|
[HttpGet("/suggestions/queue")]
|
|
[Authorize(Roles = AdminRole + "," + UberAdminRole)]
|
|
[ProducesResponseType(typeof(List<SuggestionDto>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
public async Task<ActionResult<List<SuggestionDto>>> GetQueueAsync(
|
|
[FromQuery] bool includeHistory = false,
|
|
[FromQuery] int page = 1,
|
|
[FromQuery] int pageSize = 50)
|
|
{
|
|
if(page < 1) page = 1;
|
|
if(pageSize is < 1 or > 200) pageSize = 50;
|
|
|
|
// Only UberAdmin can opt-in to history; for everyone else silently force pending-only.
|
|
bool effectiveHistory = includeHistory && User.IsInRole(UberAdminRole);
|
|
|
|
IQueryable<Suggestion> query = context.Suggestions.AsNoTracking();
|
|
|
|
if(!effectiveHistory)
|
|
query = query.Where(s => s.Status == SuggestionStatus.Pending);
|
|
|
|
List<Suggestion> rows = await query
|
|
.OrderBy(s => s.CreatedOn)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Include(s => s.CreatedBy)
|
|
.Include(s => s.ReviewedBy)
|
|
.ToListAsync();
|
|
|
|
var output = new List<SuggestionDto>(rows.Count);
|
|
|
|
foreach(Suggestion s in rows)
|
|
{
|
|
string displayName = await ResolveDisplayNameAsync(s);
|
|
|
|
output.Add(await ProjectAsync(s, displayName));
|
|
}
|
|
|
|
return Ok(output);
|
|
}
|
|
|
|
// ───────────────────────────── GET /suggestions/{id}/diff ─────────────────────────────
|
|
|
|
[HttpGet("/suggestions/{id:long}/diff")]
|
|
[Authorize(Roles = AdminRole + "," + UberAdminRole)]
|
|
[ProducesResponseType(typeof(SuggestionDiffDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
public async Task<ActionResult<SuggestionDiffDto>> GetDiffAsync(long id)
|
|
{
|
|
Suggestion s = await context.Suggestions
|
|
.Include(x => x.CreatedBy)
|
|
.Include(x => x.ReviewedBy)
|
|
.FirstOrDefaultAsync(x => x.Id == id);
|
|
|
|
if(s is null) return NotFound();
|
|
|
|
string displayName = await ResolveDisplayNameAsync(s);
|
|
|
|
Dictionary<string, object> current = s.EntityId.HasValue
|
|
? await GetCurrentValuesAsync(s.EntityType, s.EntityId.Value, s.Subkey)
|
|
: null;
|
|
|
|
SuggestionDto suggestionDto = await ProjectAsync(s, displayName);
|
|
string secondaryLabel = await GetEntitySecondaryLabelAsync(s.EntityType, s.Subkey);
|
|
|
|
Dictionary<string, JsonElement> currentDict = ParseValuesForLabels(current);
|
|
Dictionary<string, JsonElement> suggestedDict = ParseValuesForLabels(s.SuggestedValues);
|
|
Dictionary<string, string> currentLabels = await ResolveLabelsAsync(s.EntityType, s.EntityId, currentDict);
|
|
Dictionary<string, string> suggestedLabels = await ResolveLabelsAsync(s.EntityType, s.EntityId, suggestedDict);
|
|
|
|
// Machine entity-edit suggestions encode junction add/remove ops as dynamic keys in
|
|
// the SUGGESTED payload only. Adds resolve from the suggested payload's id field
|
|
// (already handled by ResolveMachineLabelsAsync via the suggested-side ResolveLabelsAsync
|
|
// pass above). Removes need a DB lookup of the existing junction row, but the label
|
|
// belongs in the CURRENT-side map so the diff panel renders the readable name in the
|
|
// strikethrough cell. The current-side GetCurrentValuesAsync only returns scalar fields,
|
|
// so we make a dedicated pass here. CRITICAL: iterate over the RAW SuggestedValues dict
|
|
// (NOT the parsed suggestedDict) because ParseValuesForLabels skips null-valued entries
|
|
// and junction-remove keys carry a null value by design.
|
|
if(s.EntityType == SuggestionEntityType.Machine && s.EntityId.HasValue && s.SuggestedValues is not null)
|
|
await ResolveMachineRemoveLabelsAsync((int)s.EntityId.Value, s.SuggestedValues, currentLabels);
|
|
|
|
// Same pattern for Book entity-edit suggestions: junction-remove labels need a DB
|
|
// lookup constrained to the targeted book and the result lands in the current-side
|
|
// label map so the diff panel renders the readable name in the strikethrough cell.
|
|
// CRITICAL (same lesson as Machine): iterate the RAW SuggestedValues dict because
|
|
// ParseValuesForLabels skips null-valued entries and remove keys carry a null value.
|
|
if(s.EntityType == SuggestionEntityType.Book && s.EntityId.HasValue && s.SuggestedValues is not null)
|
|
await ResolveBookRemoveLabelsAsync(s.EntityId.Value, s.SuggestedValues, currentLabels);
|
|
|
|
// Same pattern for Document entity-edit suggestions: junction-remove labels need a DB
|
|
// lookup constrained to the targeted document and the result lands in the current-side
|
|
// label map so the diff panel renders the readable name in the strikethrough cell.
|
|
// CRITICAL (same lesson as Machine/Book): iterate the RAW SuggestedValues dict because
|
|
// ParseValuesForLabels skips null-valued entries and remove keys carry a null value.
|
|
if(s.EntityType == SuggestionEntityType.Document && s.EntityId.HasValue && s.SuggestedValues is not null)
|
|
await ResolveDocumentRemoveLabelsAsync(s.EntityId.Value, s.SuggestedValues, currentLabels);
|
|
|
|
// Same pattern for Magazine entity-edit suggestions (single Companies junction). People
|
|
// belong to MagazineIssue, not Magazine, so they're absent here.
|
|
if(s.EntityType == SuggestionEntityType.Magazine && s.EntityId.HasValue && s.SuggestedValues is not null)
|
|
await ResolveMagazineRemoveLabelsAsync(s.EntityId.Value, s.SuggestedValues, currentLabels);
|
|
|
|
// Same pattern for MagazineIssue entity-edit suggestions (4 junctions: people,
|
|
// machines, machine_families, software). NB: the People/Machines/MachineFamilies/
|
|
// Software junctions all hang off MagazineIssue, not the parent Magazine — see
|
|
// MagazineIssueSuggestionApplier for the column-name caveat.
|
|
if(s.EntityType == SuggestionEntityType.MagazineIssue && s.EntityId.HasValue && s.SuggestedValues is not null)
|
|
await ResolveMagazineIssueRemoveLabelsAsync(s.EntityId.Value, s.SuggestedValues, currentLabels);
|
|
|
|
// Same pattern for Gpu entity-edit suggestions (single Resolutions junction). The
|
|
// junction-remove labels need a DB lookup of the existing ResolutionsByGpu row
|
|
// joined to the underlying Resolution, and the result lands in the current-side
|
|
// label map so the diff panel renders the readable name in the strikethrough cell.
|
|
if(s.EntityType == SuggestionEntityType.Gpu && s.EntityId.HasValue && s.SuggestedValues is not null)
|
|
await ResolveGpuRemoveLabelsAsync(s.EntityId.Value, s.SuggestedValues, currentLabels);
|
|
|
|
// Same pattern for Processor entity-edit suggestions (single Instruction Set
|
|
// Extensions junction). Mirrors the Gpu shape: int-keyed, no cover, simple-FK
|
|
// junction without a role column.
|
|
if(s.EntityType == SuggestionEntityType.Processor && s.EntityId.HasValue && s.SuggestedValues is not null)
|
|
await ResolveProcessorRemoveLabelsAsync(s.EntityId.Value, s.SuggestedValues, currentLabels);
|
|
|
|
// Same pattern for Software entity-edit suggestions (single Genres junction).
|
|
// Software is ulong-keyed; the junction is a composite-key table (SoftwareId,
|
|
// GenreId) with NO surrogate Id, so the remove token IS the genre id rather than a
|
|
// row id — see SoftwareSuggestionApplier.ApplyJunctionRemove for the lookup.
|
|
if(s.EntityType == SuggestionEntityType.Software && s.EntityId.HasValue && s.SuggestedValues is not null)
|
|
await ResolveSoftwareRemoveLabelsAsync(s.EntityId.Value, s.SuggestedValues, currentLabels);
|
|
|
|
// Same pattern for SoftwareRelease entity-edit suggestions (4 junctions: regions,
|
|
// languages, barcodes, product_codes). Mixes 2 composite-key junctions (regions
|
|
// short FK, languages string FK) with 2 surrogate-Id junctions (barcodes/
|
|
// product_codes), so the per-group token parser dispatches by group — see
|
|
// SoftwareReleaseSuggestionApplier.ApplyJunctionRemove for the per-group lookup.
|
|
if(s.EntityType == SuggestionEntityType.SoftwareRelease && s.EntityId.HasValue && s.SuggestedValues is not null)
|
|
await ResolveSoftwareReleaseRemoveLabelsAsync(s.EntityId.Value, s.SuggestedValues, currentLabels);
|
|
|
|
return Ok(new SuggestionDiffDto
|
|
{
|
|
Suggestion = suggestionDto,
|
|
CurrentValuesJson = SuggestionsHelper.SerializeValues(current),
|
|
EntityMissing = s.EntityId.HasValue && current is null,
|
|
EntitySecondaryLabel = secondaryLabel,
|
|
CurrentLabels = currentLabels.Count > 0 ? currentLabels : null,
|
|
SuggestedLabels = suggestedLabels.Count > 0 ? suggestedLabels : null
|
|
});
|
|
}
|
|
|
|
// ───────────────────────────── POST /suggestions/{id}/review ─────────────────────────────
|
|
|
|
[HttpPost("/suggestions/{id:long}/review")]
|
|
[Authorize(Roles = AdminRole + "," + UberAdminRole)]
|
|
[ProducesResponseType(typeof(SuggestionDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
public async Task<ActionResult<SuggestionDto>> ReviewAsync(long id, [FromBody] SuggestionReviewDto review)
|
|
{
|
|
string adminId = User.FindFirstValue(ClaimTypes.Sid);
|
|
if(string.IsNullOrEmpty(adminId)) return Unauthorized();
|
|
if(review is null) return BadRequest();
|
|
|
|
Suggestion s = await context.Suggestions.FirstOrDefaultAsync(x => x.Id == id);
|
|
if(s is null) return NotFound();
|
|
|
|
if(s.Status != SuggestionStatus.Pending)
|
|
return Problem(title: "Already reviewed",
|
|
detail: "This suggestion has already been reviewed.",
|
|
statusCode: StatusCodes.Status409Conflict);
|
|
|
|
Dictionary<string, object> suggested = s.SuggestedValues ?? new Dictionary<string, object>();
|
|
|
|
// Filter accepted to only those keys actually in the suggestion. Dynamic accept-key
|
|
// patterns (e.g. GpuPhoto's `photo.<guid>` per-photo toggles) are NOT literal keys in
|
|
// SuggestedValues — they're synthetic field names derived from a JSON array element
|
|
// by the diff panel — so intersecting against suggested.Keys would drop them. Keep
|
|
// them when the per-entity validator recognises the shape.
|
|
HashSet<string> accepted = new(review.AcceptedFieldNames ?? new List<string>(), StringComparer.Ordinal);
|
|
accepted.RemoveWhere(k => !suggested.ContainsKey(k) && !IsKnownFieldName(s.EntityType, k));
|
|
|
|
// Capture admin's optional review comment (e.g. reason for rejection). Truncate to
|
|
// the column max so the row never overflows.
|
|
string adminComment = string.IsNullOrWhiteSpace(review.AdminComment) ? null : review.AdminComment.Trim();
|
|
if(adminComment is { Length: > MaxUserCommentLength })
|
|
adminComment = adminComment.Substring(0, MaxUserCommentLength);
|
|
s.AdminReviewComment = adminComment;
|
|
|
|
// Capture this BEFORE the addition path mutates s.EntityId, so the message dispatcher
|
|
// can choose addition-flavoured wording afterwards.
|
|
bool wasAddition = !s.EntityId.HasValue;
|
|
|
|
// Apply via per-entity dispatch — branch on edit vs. addition.
|
|
if(s.EntityId.HasValue && accepted.Count > 0)
|
|
{
|
|
ApplyResult result = await ApplyAcceptedFieldsAsync(s.EntityType, s.EntityId.Value, s.Subkey, suggested, accepted, s.CreatedById);
|
|
|
|
if(result.EntityMissing)
|
|
{
|
|
s.Status = SuggestionStatus.Stale;
|
|
s.ReviewedById = adminId;
|
|
s.ReviewedOn = DateTime.UtcNow;
|
|
await context.SaveChangesAsync();
|
|
return Problem(title: "Entity no longer exists",
|
|
detail: "The targeted entity has been deleted; the suggestion was closed as stale.",
|
|
statusCode: StatusCodes.Status404NotFound);
|
|
}
|
|
|
|
// Whatever the applier actually wrote (it may have skipped invalid values).
|
|
accepted = result.Applied;
|
|
}
|
|
else if(!s.EntityId.HasValue && accepted.Count > 0)
|
|
{
|
|
// Addition path: create a brand-new entity row, then patch s.EntityId so the
|
|
// outgoing message carries a clickable link and the projected DTO has a real id.
|
|
(long? newId, HashSet<string> applied) = await CreateNewEntityAsync(
|
|
s.EntityType, suggested, accepted, s.CreatedById);
|
|
|
|
if(newId.HasValue)
|
|
s.EntityId = newId.Value;
|
|
|
|
// If creation failed (e.g. admin didn't accept the mandatory 'name' field), the
|
|
// applied set is empty and the suggestion will be marked Rejected below.
|
|
accepted = applied;
|
|
}
|
|
|
|
// Compute terminal status. GpuPhoto, ProcessorPhoto and SoundSynthPhoto batches need
|
|
// entity-specific accounting because the wire payload's scalar key set (`license_id`,
|
|
// `source_url`, `photos`) bears no relation to the per-photo accept toggles
|
|
// (`photo.<guid>`) the admin actually checks: `accepted.Count == suggested.Count` is
|
|
// meaningless there. For these batches we compare the COUNT of accepted `photo.<guid>`
|
|
// keys against the size of the `photos` JSON array.
|
|
SuggestionStatus newStatus;
|
|
|
|
if(s.EntityType == SuggestionEntityType.GpuPhoto)
|
|
{
|
|
int totalPhotos = CountSuggestedGpuPhotos(s);
|
|
int acceptedPhotos = accepted.Count(k => k.StartsWith("photo.", StringComparison.Ordinal));
|
|
|
|
if(acceptedPhotos == 0)
|
|
newStatus = SuggestionStatus.Rejected;
|
|
else if(totalPhotos > 0 && acceptedPhotos >= totalPhotos)
|
|
newStatus = SuggestionStatus.Accepted;
|
|
else
|
|
newStatus = SuggestionStatus.PartiallyAccepted;
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.ProcessorPhoto)
|
|
{
|
|
int totalPhotos = CountSuggestedProcessorPhotos(s);
|
|
int acceptedPhotos = accepted.Count(k => k.StartsWith("photo.", StringComparison.Ordinal));
|
|
|
|
if(acceptedPhotos == 0)
|
|
newStatus = SuggestionStatus.Rejected;
|
|
else if(totalPhotos > 0 && acceptedPhotos >= totalPhotos)
|
|
newStatus = SuggestionStatus.Accepted;
|
|
else
|
|
newStatus = SuggestionStatus.PartiallyAccepted;
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.SoundSynthPhoto)
|
|
{
|
|
int totalPhotos = CountSuggestedSoundSynthPhotos(s);
|
|
int acceptedPhotos = accepted.Count(k => k.StartsWith("photo.", StringComparison.Ordinal));
|
|
|
|
if(acceptedPhotos == 0)
|
|
newStatus = SuggestionStatus.Rejected;
|
|
else if(totalPhotos > 0 && acceptedPhotos >= totalPhotos)
|
|
newStatus = SuggestionStatus.Accepted;
|
|
else
|
|
newStatus = SuggestionStatus.PartiallyAccepted;
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.MachinePhoto)
|
|
{
|
|
int totalPhotos = CountSuggestedMachinePhotos(s);
|
|
int acceptedPhotos = accepted.Count(k => k.StartsWith("photo.", StringComparison.Ordinal));
|
|
|
|
if(acceptedPhotos == 0)
|
|
newStatus = SuggestionStatus.Rejected;
|
|
else if(totalPhotos > 0 && acceptedPhotos >= totalPhotos)
|
|
newStatus = SuggestionStatus.Accepted;
|
|
else
|
|
newStatus = SuggestionStatus.PartiallyAccepted;
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.SoftwarePromoArt)
|
|
{
|
|
int totalPhotos = CountSuggestedSoftwarePromoArt(s);
|
|
int acceptedPhotos = accepted.Count(k => k.StartsWith("promo.", StringComparison.Ordinal));
|
|
|
|
if(acceptedPhotos == 0)
|
|
newStatus = SuggestionStatus.Rejected;
|
|
else if(totalPhotos > 0 && acceptedPhotos >= totalPhotos)
|
|
newStatus = SuggestionStatus.Accepted;
|
|
else
|
|
newStatus = SuggestionStatus.PartiallyAccepted;
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.SoftwareCover)
|
|
{
|
|
int totalCovers = CountSuggestedSoftwareCovers(s);
|
|
int acceptedCovers = accepted.Count(k => k.StartsWith("cover.", StringComparison.Ordinal));
|
|
|
|
if(acceptedCovers == 0)
|
|
newStatus = SuggestionStatus.Rejected;
|
|
else if(totalCovers > 0 && acceptedCovers >= totalCovers)
|
|
newStatus = SuggestionStatus.Accepted;
|
|
else
|
|
newStatus = SuggestionStatus.PartiallyAccepted;
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.SoftwareScreenshot)
|
|
{
|
|
int totalScreenshots = CountSuggestedSoftwareScreenshots(s);
|
|
int acceptedScreenshots = accepted.Count(k => k.StartsWith("screenshot.", StringComparison.Ordinal));
|
|
|
|
if(acceptedScreenshots == 0)
|
|
newStatus = SuggestionStatus.Rejected;
|
|
else if(totalScreenshots > 0 && acceptedScreenshots >= totalScreenshots)
|
|
newStatus = SuggestionStatus.Accepted;
|
|
else
|
|
newStatus = SuggestionStatus.PartiallyAccepted;
|
|
}
|
|
else if(accepted.Count == 0)
|
|
newStatus = SuggestionStatus.Rejected;
|
|
else if(accepted.Count == suggested.Count)
|
|
newStatus = SuggestionStatus.Accepted;
|
|
else
|
|
newStatus = SuggestionStatus.PartiallyAccepted;
|
|
|
|
s.Status = newStatus;
|
|
s.ReviewedById = adminId;
|
|
s.ReviewedOn = DateTime.UtcNow;
|
|
s.AppliedFields = accepted.ToDictionary(k => k, _ => string.Empty, StringComparer.Ordinal);
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
// Pending-image cleanup: every suggestion that carried a cover_pending_guid in its
|
|
// payload but did NOT end up applying the cover (rejected, or partially accepted
|
|
// with the cover unchecked) leaves an orphaned pending file on disk. Sweep them
|
|
// here so the pending folder doesn't grow unbounded. Cleanup is best-effort —
|
|
// failures don't block the review flow.
|
|
CleanupPendingMedia(s, accepted);
|
|
|
|
// Grant Collaborator role + send appropriate system message.
|
|
bool granted = false;
|
|
|
|
if(accepted.Count > 0)
|
|
{
|
|
ApplicationUser suggestingUser = await userManager.FindByIdAsync(s.CreatedById);
|
|
|
|
if(suggestingUser is not null)
|
|
{
|
|
bool isAdmin = await userManager.IsInRoleAsync(suggestingUser, AdminRole);
|
|
bool isUberAdmin = await userManager.IsInRoleAsync(suggestingUser, UberAdminRole);
|
|
bool isCollab = await userManager.IsInRoleAsync(suggestingUser, CollaboratorRole);
|
|
|
|
if(!isAdmin && !isUberAdmin && !isCollab)
|
|
{
|
|
var roleResult = await userManager.AddToRoleAsync(suggestingUser, CollaboratorRole);
|
|
granted = roleResult.Succeeded;
|
|
}
|
|
}
|
|
}
|
|
|
|
string displayName = await ResolveDisplayNameAsync(s);
|
|
|
|
await DispatchReviewMessageAsync(s, displayName, accepted.Count, suggested.Count, granted, wasAddition);
|
|
|
|
return Ok(await ProjectAsync(s, displayName));
|
|
}
|
|
|
|
// ───────────────────────────── GET /auth/me/suggestions ─────────────────────────────
|
|
|
|
[HttpGet("/auth/me/suggestions")]
|
|
[ProducesResponseType(typeof(List<SuggestionDto>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
public async Task<ActionResult<List<SuggestionDto>>> GetMyAsync()
|
|
{
|
|
string userId = User.FindFirstValue(ClaimTypes.Sid);
|
|
if(string.IsNullOrEmpty(userId)) return Unauthorized();
|
|
|
|
List<Suggestion> rows = await context.Suggestions
|
|
.AsNoTracking()
|
|
.Where(s => s.CreatedById == userId)
|
|
.OrderByDescending(s => s.CreatedOn)
|
|
.Take(200)
|
|
.Include(s => s.CreatedBy)
|
|
.Include(s => s.ReviewedBy)
|
|
.ToListAsync();
|
|
|
|
var output = new List<SuggestionDto>(rows.Count);
|
|
|
|
foreach(Suggestion s in rows)
|
|
{
|
|
string displayName = await ResolveDisplayNameAsync(s);
|
|
|
|
output.Add(await ProjectAsync(s, displayName));
|
|
}
|
|
|
|
return Ok(output);
|
|
}
|
|
|
|
// ───────────────────────────── DELETE /auth/me/suggestions/{id} ─────────────────────────────
|
|
|
|
[HttpDelete("/auth/me/suggestions/{id:long}")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
public async Task<IActionResult> WithdrawAsync(long id)
|
|
{
|
|
string userId = User.FindFirstValue(ClaimTypes.Sid);
|
|
if(string.IsNullOrEmpty(userId)) return Unauthorized();
|
|
|
|
Suggestion s = await context.Suggestions.FirstOrDefaultAsync(x => x.Id == id);
|
|
|
|
if(s is null || s.CreatedById != userId) return NotFound();
|
|
|
|
if(s.Status != SuggestionStatus.Pending)
|
|
return Problem(title: "Cannot withdraw",
|
|
detail: "Only pending suggestions can be withdrawn.",
|
|
statusCode: StatusCodes.Status409Conflict);
|
|
|
|
s.Status = SuggestionStatus.Withdrawn;
|
|
s.ReviewedOn = DateTime.UtcNow;
|
|
await context.SaveChangesAsync();
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
// ───────────────────────────── Per-entity dispatch ─────────────────────────────
|
|
|
|
record ApplyResult(HashSet<string> Applied, bool EntityMissing);
|
|
|
|
/// <summary>
|
|
/// Returns the canonical field names supported by the suggestion metadata for
|
|
/// the given entity type. Returns <c>null</c> for entities not yet wired in
|
|
/// Phase 1 — the controller rejects suggestions for those with HTTP 400.
|
|
/// </summary>
|
|
static IReadOnlyCollection<string> GetKnownFieldNames(SuggestionEntityType type) => type switch
|
|
{
|
|
SuggestionEntityType.Company => Suggestions.CompanySuggestionApplier.KnownFieldNames,
|
|
SuggestionEntityType.CompanyDescription => Suggestions.CompanyDescriptionSuggestionApplier.KnownFieldNames,
|
|
SuggestionEntityType.MachineDescription => Suggestions.MachineDescriptionSuggestionApplier.KnownFieldNames,
|
|
SuggestionEntityType.BookSynopsis => Suggestions.BookSynopsisSuggestionApplier.KnownFieldNames,
|
|
SuggestionEntityType.DocumentSynopsis => Suggestions.DocumentSynopsisSuggestionApplier.KnownFieldNames,
|
|
SuggestionEntityType.MagazineSynopsis => Suggestions.MagazineSynopsisSuggestionApplier.KnownFieldNames,
|
|
SuggestionEntityType.GpuDescription => Suggestions.GpuDescriptionSuggestionApplier.KnownFieldNames,
|
|
SuggestionEntityType.ProcessorDescription => Suggestions.ProcessorDescriptionSuggestionApplier.KnownFieldNames,
|
|
SuggestionEntityType.SoundSynthDescription => Suggestions.SoundSynthDescriptionSuggestionApplier.KnownFieldNames,
|
|
SuggestionEntityType.PersonDescription => Suggestions.PersonDescriptionSuggestionApplier.KnownFieldNames,
|
|
SuggestionEntityType.SoftwareDescription => Suggestions.SoftwareDescriptionSuggestionApplier.KnownFieldNames,
|
|
// Phase 3+ adds more cases here.
|
|
_ => null
|
|
};
|
|
|
|
/// <summary>
|
|
/// Returns <c>true</c> when the entity type has a registered suggestion applier (either
|
|
/// a static <c>KnownFieldNames</c> set or a dynamic <c>IsKnownFieldName</c> predicate).
|
|
/// </summary>
|
|
static bool IsEntityTypeSupported(SuggestionEntityType type) => type switch
|
|
{
|
|
SuggestionEntityType.Machine => true,
|
|
SuggestionEntityType.Book => true,
|
|
SuggestionEntityType.Document => true,
|
|
SuggestionEntityType.Magazine => true,
|
|
SuggestionEntityType.MagazineIssue => true,
|
|
SuggestionEntityType.Gpu => true,
|
|
SuggestionEntityType.Processor => true,
|
|
SuggestionEntityType.SoundSynth => true,
|
|
SuggestionEntityType.Person => true,
|
|
SuggestionEntityType.Software => true,
|
|
SuggestionEntityType.SoftwareRelease => true,
|
|
SuggestionEntityType.SoftwareVersion => true,
|
|
SuggestionEntityType.GpuPhoto => true,
|
|
SuggestionEntityType.ProcessorPhoto => true,
|
|
SuggestionEntityType.SoundSynthPhoto => true,
|
|
SuggestionEntityType.MachinePhoto => true,
|
|
SuggestionEntityType.SoftwarePromoArt => true,
|
|
SuggestionEntityType.SoftwareCover => true,
|
|
SuggestionEntityType.SoftwareScreenshot => true,
|
|
SuggestionEntityType.GpuVideo => true,
|
|
SuggestionEntityType.ProcessorVideo => true,
|
|
SuggestionEntityType.SoundSynthVideo => true,
|
|
SuggestionEntityType.SoftwareVideo => true,
|
|
SuggestionEntityType.MachineVideo => true,
|
|
_ => GetKnownFieldNames(type) is not null
|
|
};
|
|
|
|
/// <summary>
|
|
/// Per-entity field-name validator. Wraps the static <c>KnownFieldNames</c> HashSet
|
|
/// check for entities with a fixed scalar field set, and dispatches to a per-applier
|
|
/// <c>IsKnownFieldName</c> predicate for entities that accept dynamic field-name
|
|
/// patterns (e.g. <c><group>.add.<uuid></c> for Machine junction operations).
|
|
/// </summary>
|
|
static bool IsKnownFieldName(SuggestionEntityType type, string fieldName)
|
|
{
|
|
if(string.IsNullOrEmpty(fieldName)) return false;
|
|
|
|
if(type == SuggestionEntityType.Machine)
|
|
return Suggestions.MachineSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.Book)
|
|
return Suggestions.BookSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.Document)
|
|
return Suggestions.DocumentSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.Magazine)
|
|
return Suggestions.MagazineSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.MagazineIssue)
|
|
return Suggestions.MagazineIssueSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.Gpu)
|
|
return Suggestions.GpuSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.Processor)
|
|
return Suggestions.ProcessorSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.SoundSynth)
|
|
return Suggestions.SoundSynthSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.Person)
|
|
return Suggestions.PersonSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.Software)
|
|
return Suggestions.SoftwareSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.SoftwareRelease)
|
|
return Suggestions.SoftwareReleaseSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.SoftwareVersion)
|
|
return Suggestions.SoftwareVersionSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.GpuPhoto)
|
|
return Suggestions.GpuPhotoSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.ProcessorPhoto)
|
|
return Suggestions.ProcessorPhotoSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.SoundSynthPhoto)
|
|
return Suggestions.SoundSynthPhotoSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.MachinePhoto)
|
|
return Suggestions.MachinePhotoSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.SoftwarePromoArt)
|
|
return Suggestions.SoftwarePromoArtSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.SoftwareCover)
|
|
return Suggestions.SoftwareCoverSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.SoftwareScreenshot)
|
|
return Suggestions.SoftwareScreenshotSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.GpuVideo)
|
|
return Suggestions.GpuVideoSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.ProcessorVideo)
|
|
return Suggestions.ProcessorVideoSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.SoundSynthVideo)
|
|
return Suggestions.SoundSynthVideoSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.SoftwareVideo)
|
|
return Suggestions.SoftwareVideoSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
if(type == SuggestionEntityType.MachineVideo)
|
|
return Suggestions.MachineVideoSuggestionApplier.IsKnownFieldName(fieldName);
|
|
|
|
IReadOnlyCollection<string> set = GetKnownFieldNames(type);
|
|
return set is not null && set.Contains(fieldName);
|
|
}
|
|
|
|
async Task<ApplyResult> ApplyAcceptedFieldsAsync(SuggestionEntityType type,
|
|
long entityId,
|
|
string subkey,
|
|
Dictionary<string, object> suggested,
|
|
HashSet<string> accepted,
|
|
string creditedUserId)
|
|
{
|
|
switch(type)
|
|
{
|
|
case SuggestionEntityType.Company:
|
|
{
|
|
var (applied, missing) = await Suggestions.CompanySuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.CompanyDescription:
|
|
{
|
|
var (applied, missing) = await Suggestions.CompanyDescriptionSuggestionApplier.ApplyAsync(
|
|
context, entityId, subkey, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.MachineDescription:
|
|
{
|
|
var (applied, missing) = await Suggestions.MachineDescriptionSuggestionApplier.ApplyAsync(
|
|
context, entityId, subkey, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.BookSynopsis:
|
|
{
|
|
var (applied, missing) = await Suggestions.BookSynopsisSuggestionApplier.ApplyAsync(
|
|
context, entityId, subkey, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.DocumentSynopsis:
|
|
{
|
|
var (applied, missing) = await Suggestions.DocumentSynopsisSuggestionApplier.ApplyAsync(
|
|
context, entityId, subkey, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.MagazineSynopsis:
|
|
{
|
|
var (applied, missing) = await Suggestions.MagazineSynopsisSuggestionApplier.ApplyAsync(
|
|
context, entityId, subkey, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.GpuDescription:
|
|
{
|
|
var (applied, missing) = await Suggestions.GpuDescriptionSuggestionApplier.ApplyAsync(
|
|
context, entityId, subkey, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.ProcessorDescription:
|
|
{
|
|
var (applied, missing) = await Suggestions.ProcessorDescriptionSuggestionApplier.ApplyAsync(
|
|
context, entityId, subkey, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoundSynthDescription:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoundSynthDescriptionSuggestionApplier.ApplyAsync(
|
|
context, entityId, subkey, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.PersonDescription:
|
|
{
|
|
var (applied, missing) = await Suggestions.PersonDescriptionSuggestionApplier.ApplyAsync(
|
|
context, entityId, subkey, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoftwareDescription:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoftwareDescriptionSuggestionApplier.ApplyAsync(
|
|
context, entityId, subkey, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.Machine:
|
|
{
|
|
var (applied, missing) = await Suggestions.MachineSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.Book:
|
|
{
|
|
var (applied, missing) = await Suggestions.BookSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, _assetRootPath);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.Document:
|
|
{
|
|
var (applied, missing) = await Suggestions.DocumentSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.Magazine:
|
|
{
|
|
var (applied, missing) = await Suggestions.MagazineSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.MagazineIssue:
|
|
{
|
|
var (applied, missing) = await Suggestions.MagazineIssueSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, _assetRootPath);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.Gpu:
|
|
{
|
|
var (applied, missing) = await Suggestions.GpuSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.Processor:
|
|
{
|
|
var (applied, missing) = await Suggestions.ProcessorSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoundSynth:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoundSynthSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.Person:
|
|
{
|
|
var (applied, missing) = await Suggestions.PersonSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, _assetRootPath);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.Software:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoftwareSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoftwareRelease:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoftwareReleaseSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoftwareVersion:
|
|
{
|
|
// Edit-mode is intentionally stubbed (creation-only port). The applier
|
|
// returns (empty, false) so the suggestion accepts cleanly with no field
|
|
// application — admins shouldn't reach this path through the UI today.
|
|
var (applied, missing) = await Suggestions.SoftwareVersionSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.GpuPhoto:
|
|
{
|
|
var (applied, missing) = await Suggestions.GpuPhotoSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId, _assetRootPath);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.ProcessorPhoto:
|
|
{
|
|
var (applied, missing) = await Suggestions.ProcessorPhotoSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId, _assetRootPath);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoundSynthPhoto:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoundSynthPhotoSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId, _assetRootPath);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.MachinePhoto:
|
|
{
|
|
var (applied, missing) = await Suggestions.MachinePhotoSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId, _assetRootPath);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoftwarePromoArt:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoftwarePromoArtSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId, _assetRootPath);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoftwareCover:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoftwareCoverSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId, _assetRootPath);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoftwareScreenshot:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoftwareScreenshotSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId, _assetRootPath);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.GpuVideo:
|
|
{
|
|
var (applied, missing) = await Suggestions.GpuVideoSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.ProcessorVideo:
|
|
{
|
|
var (applied, missing) = await Suggestions.ProcessorVideoSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoundSynthVideo:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoundSynthVideoSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.MachineVideo:
|
|
{
|
|
var (applied, missing) = await Suggestions.MachineVideoSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
case SuggestionEntityType.SoftwareVideo:
|
|
{
|
|
var (applied, missing) = await Suggestions.SoftwareVideoSuggestionApplier.ApplyAsync(
|
|
context, entityId, suggested, accepted, creditedUserId);
|
|
return new ApplyResult(applied, missing);
|
|
}
|
|
default:
|
|
throw new NotImplementedException($"Suggestions for {type} are not implemented yet.");
|
|
}
|
|
}
|
|
|
|
async Task<Dictionary<string, object>> GetCurrentValuesAsync(SuggestionEntityType type,
|
|
long entityId,
|
|
string subkey)
|
|
{
|
|
return type switch
|
|
{
|
|
SuggestionEntityType.Company =>
|
|
await Suggestions.CompanySuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.CompanyDescription =>
|
|
await Suggestions.CompanyDescriptionSuggestionApplier.GetCurrentValuesAsync(context, entityId, subkey),
|
|
SuggestionEntityType.MachineDescription =>
|
|
await Suggestions.MachineDescriptionSuggestionApplier.GetCurrentValuesAsync(context, entityId, subkey),
|
|
SuggestionEntityType.BookSynopsis =>
|
|
await Suggestions.BookSynopsisSuggestionApplier.GetCurrentValuesAsync(context, entityId, subkey),
|
|
SuggestionEntityType.DocumentSynopsis =>
|
|
await Suggestions.DocumentSynopsisSuggestionApplier.GetCurrentValuesAsync(context, entityId, subkey),
|
|
SuggestionEntityType.MagazineSynopsis =>
|
|
await Suggestions.MagazineSynopsisSuggestionApplier.GetCurrentValuesAsync(context, entityId, subkey),
|
|
SuggestionEntityType.GpuDescription =>
|
|
await Suggestions.GpuDescriptionSuggestionApplier.GetCurrentValuesAsync(context, entityId, subkey),
|
|
SuggestionEntityType.ProcessorDescription =>
|
|
await Suggestions.ProcessorDescriptionSuggestionApplier.GetCurrentValuesAsync(context, entityId, subkey),
|
|
SuggestionEntityType.SoundSynthDescription =>
|
|
await Suggestions.SoundSynthDescriptionSuggestionApplier.GetCurrentValuesAsync(context, entityId, subkey),
|
|
SuggestionEntityType.PersonDescription =>
|
|
await Suggestions.PersonDescriptionSuggestionApplier.GetCurrentValuesAsync(context, entityId, subkey),
|
|
SuggestionEntityType.SoftwareDescription =>
|
|
await Suggestions.SoftwareDescriptionSuggestionApplier.GetCurrentValuesAsync(context, entityId, subkey),
|
|
SuggestionEntityType.Machine =>
|
|
await Suggestions.MachineSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.Book =>
|
|
await Suggestions.BookSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.Document =>
|
|
await Suggestions.DocumentSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.Magazine =>
|
|
await Suggestions.MagazineSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.MagazineIssue =>
|
|
await Suggestions.MagazineIssueSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.Gpu =>
|
|
await Suggestions.GpuSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.Processor =>
|
|
await Suggestions.ProcessorSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.SoundSynth =>
|
|
await Suggestions.SoundSynthSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.Person =>
|
|
await Suggestions.PersonSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.Software =>
|
|
await Suggestions.SoftwareSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.SoftwareRelease =>
|
|
await Suggestions.SoftwareReleaseSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.GpuPhoto =>
|
|
await Suggestions.GpuPhotoSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.ProcessorPhoto =>
|
|
await Suggestions.ProcessorPhotoSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.SoundSynthPhoto =>
|
|
await Suggestions.SoundSynthPhotoSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.MachinePhoto =>
|
|
await Suggestions.MachinePhotoSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.SoftwarePromoArt =>
|
|
await Suggestions.SoftwarePromoArtSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.SoftwareCover =>
|
|
await Suggestions.SoftwareCoverSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.SoftwareScreenshot =>
|
|
await Suggestions.SoftwareScreenshotSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.GpuVideo =>
|
|
await Suggestions.GpuVideoSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.ProcessorVideo =>
|
|
await Suggestions.ProcessorVideoSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.SoundSynthVideo =>
|
|
await Suggestions.SoundSynthVideoSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.SoftwareVideo =>
|
|
await Suggestions.SoftwareVideoSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
SuggestionEntityType.MachineVideo =>
|
|
await Suggestions.MachineVideoSuggestionApplier.GetCurrentValuesAsync(context, entityId),
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
async Task<string> GetEntityDisplayNameAsync(SuggestionEntityType type, long entityId)
|
|
{
|
|
switch(type)
|
|
{
|
|
case SuggestionEntityType.Company:
|
|
case SuggestionEntityType.CompanyDescription:
|
|
return await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == (int)entityId)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.Machine:
|
|
case SuggestionEntityType.MachineDescription:
|
|
case SuggestionEntityType.MachinePhoto:
|
|
case SuggestionEntityType.MachineVideo:
|
|
return await context.Machines.AsNoTracking()
|
|
.Where(m => m.Id == (int)entityId)
|
|
.Select(m => m.Name)
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.Book:
|
|
case SuggestionEntityType.BookSynopsis:
|
|
return await context.Books.AsNoTracking()
|
|
.Where(b => b.Id == entityId)
|
|
.Select(b => b.Title)
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.Document:
|
|
case SuggestionEntityType.DocumentSynopsis:
|
|
return await context.Documents.AsNoTracking()
|
|
.Where(d => d.Id == entityId)
|
|
.Select(d => d.Title)
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.Magazine:
|
|
case SuggestionEntityType.MagazineSynopsis:
|
|
return await context.Magazines.AsNoTracking()
|
|
.Where(m => m.Id == entityId)
|
|
.Select(m => m.Title)
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.MagazineIssue:
|
|
return await context.MagazineIssues.AsNoTracking()
|
|
.Where(mi => mi.Id == entityId)
|
|
.Select(mi => mi.Caption)
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.Gpu:
|
|
case SuggestionEntityType.GpuDescription:
|
|
case SuggestionEntityType.GpuPhoto:
|
|
case SuggestionEntityType.GpuVideo:
|
|
return await context.Gpus.AsNoTracking()
|
|
.Where(g => g.Id == (int)entityId)
|
|
.Select(g => g.Name)
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.Processor:
|
|
case SuggestionEntityType.ProcessorDescription:
|
|
case SuggestionEntityType.ProcessorPhoto:
|
|
case SuggestionEntityType.ProcessorVideo:
|
|
return await context.Processors.AsNoTracking()
|
|
.Where(p => p.Id == (int)entityId)
|
|
.Select(p => p.Name)
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.SoundSynth:
|
|
case SuggestionEntityType.SoundSynthDescription:
|
|
case SuggestionEntityType.SoundSynthPhoto:
|
|
case SuggestionEntityType.SoundSynthVideo:
|
|
return await context.SoundSynths.AsNoTracking()
|
|
.Where(s => s.Id == (int)entityId)
|
|
.Select(s => s.Name)
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.Person:
|
|
case SuggestionEntityType.PersonDescription:
|
|
return await context.People.AsNoTracking()
|
|
.Where(p => p.Id == (int)entityId)
|
|
.Select(p => p.DisplayName ?? (p.Name + " " + p.Surname))
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.Software:
|
|
case SuggestionEntityType.SoftwareDescription:
|
|
case SuggestionEntityType.SoftwarePromoArt:
|
|
case SuggestionEntityType.SoftwareScreenshot:
|
|
case SuggestionEntityType.SoftwareVideo:
|
|
return await context.Softwares.AsNoTracking()
|
|
.Where(s => s.Id == (ulong)entityId)
|
|
.Select(s => s.Name)
|
|
.FirstOrDefaultAsync();
|
|
case SuggestionEntityType.SoftwareRelease:
|
|
case SuggestionEntityType.SoftwareCover:
|
|
{
|
|
// Surface BOTH the parent software/version AND the release title so the
|
|
// suggestion queue row reads e.g. "Quake 1.01 — Shareware" rather than just
|
|
// one piece. Falls back gracefully when one side is missing.
|
|
var row = await context.SoftwareReleases.AsNoTracking()
|
|
.Where(r => r.Id == (ulong)entityId)
|
|
.Select(r => new
|
|
{
|
|
r.Title,
|
|
SoftwareName = r.Software.Name,
|
|
VersionString = r.SoftwareVersion.VersionString
|
|
})
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
string parent = !string.IsNullOrWhiteSpace(row.SoftwareName)
|
|
? (string.IsNullOrWhiteSpace(row.VersionString)
|
|
? row.SoftwareName
|
|
: $"{row.SoftwareName} {row.VersionString}")
|
|
: null;
|
|
if(string.IsNullOrWhiteSpace(parent)) return row.Title;
|
|
if(string.IsNullOrWhiteSpace(row.Title)) return parent;
|
|
return $"{parent} \u2014 {row.Title}";
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve the display name for a suggestion row. For edits (<c>EntityId</c> set), looks
|
|
/// up the live name from the targeted entity. For additions (<c>EntityId == null</c>),
|
|
/// extracts the name from the suggested-values payload so queue / review surfaces still
|
|
/// have a useful label before the entity is created.
|
|
/// </summary>
|
|
async Task<string> ResolveDisplayNameAsync(Suggestion s)
|
|
{
|
|
if(s.EntityId.HasValue)
|
|
return await GetEntityDisplayNameAsync(s.EntityType, s.EntityId.Value);
|
|
|
|
return ExtractAdditionDisplayName(s.EntityType, s.SuggestedValues);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-entity validation for brand-new-entity submissions. Returns <c>null</c> when
|
|
/// the payload passes, or a friendly error string for the 400 response detail.
|
|
/// </summary>
|
|
static string ValidateAdditionPayload(SuggestionEntityType type, Dictionary<string, object> values)
|
|
{
|
|
switch(type)
|
|
{
|
|
case SuggestionEntityType.Company:
|
|
{
|
|
if(!values.TryGetValue(Suggestions.CompanySuggestionApplier.FieldName, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new company suggestion must include a non-empty 'name' field.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Machine:
|
|
{
|
|
if(!values.TryGetValue(Suggestions.MachineSuggestionApplier.FieldName, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new machine suggestion must include a non-empty 'name' field.";
|
|
if(!values.ContainsKey(Suggestions.MachineSuggestionApplier.FieldType))
|
|
return "A new machine suggestion must include a 'type' field (computer, console or smartphone).";
|
|
if(!values.ContainsKey(Suggestions.MachineSuggestionApplier.FieldCompanyId))
|
|
return "A new machine suggestion must include a 'company_id' field referencing the manufacturer.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Magazine:
|
|
{
|
|
if(!values.TryGetValue(Suggestions.MagazineSuggestionApplier.FieldTitle, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new magazine suggestion must include a non-empty 'title' field.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Book:
|
|
{
|
|
if(!values.TryGetValue(Suggestions.BookSuggestionApplier.FieldTitle, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new book suggestion must include a non-empty 'title' field.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Document:
|
|
{
|
|
if(!values.TryGetValue(Suggestions.DocumentSuggestionApplier.FieldTitle, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new document suggestion must include a non-empty 'title' field.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.MagazineIssue:
|
|
{
|
|
if(!values.TryGetValue(Suggestions.MagazineIssueSuggestionApplier.FieldMagazineId, out object _))
|
|
return "A new magazine issue suggestion must include a 'magazine_id' field referencing the parent magazine.";
|
|
if(!values.TryGetValue(Suggestions.MagazineIssueSuggestionApplier.FieldCaption, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new magazine issue suggestion must include a non-empty 'caption' field.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Gpu:
|
|
{
|
|
if(!values.TryGetValue(Suggestions.GpuSuggestionApplier.FieldName, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new GPU suggestion must include a non-empty 'name' field.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Processor:
|
|
{
|
|
if(!values.TryGetValue(Suggestions.ProcessorSuggestionApplier.FieldName, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new processor suggestion must include a non-empty 'name' field.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.SoundSynth:
|
|
{
|
|
if(!values.TryGetValue(Suggestions.SoundSynthSuggestionApplier.FieldName, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new sound synthesizer suggestion must include a non-empty 'name' field.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Person:
|
|
{
|
|
if(!values.TryGetValue(Suggestions.PersonSuggestionApplier.FieldName, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new person suggestion must include a non-empty 'name' field.";
|
|
if(!values.TryGetValue(Suggestions.PersonSuggestionApplier.FieldSurname, out object s) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(s)))
|
|
return "A new person suggestion must include a non-empty 'surname' field.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.SoftwareRelease:
|
|
{
|
|
// Per-field validation in priority order so the user gets a specific error
|
|
// for whichever mandatory field is missing first (title → publisher →
|
|
// platform → software). Matches the dialog's own per-field defence-in-depth.
|
|
if(!values.TryGetValue(Suggestions.SoftwareReleaseSuggestionApplier.FieldTitle, out object t) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(t)))
|
|
return "A new software release suggestion must include a non-empty 'title' field.";
|
|
if(!values.ContainsKey(Suggestions.SoftwareReleaseSuggestionApplier.FieldPublisherId))
|
|
return "A new software release suggestion must include a 'publisher_id' field referencing the publisher.";
|
|
if(!values.ContainsKey(Suggestions.SoftwareReleaseSuggestionApplier.FieldPlatformId))
|
|
return "A new software release suggestion must include a 'platform_id' field referencing the platform.";
|
|
if(!values.ContainsKey(Suggestions.SoftwareReleaseSuggestionApplier.FieldSoftwareId))
|
|
return "A new software release suggestion must include a 'software_id' field referencing the parent software.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Software:
|
|
{
|
|
// Software has no user-facing surface without a release, so a brand-new
|
|
// Software suggestion MUST carry its first-release in the same submission.
|
|
// Validation order: software name → kind → first_release_title →
|
|
// first_release_publisher_id → first_release_platform_id (matches the
|
|
// dialog's per-field defence-in-depth in priority order).
|
|
if(!values.TryGetValue(Suggestions.SoftwareSuggestionApplier.FieldName, out object n) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(n)))
|
|
return "A new software suggestion must include a non-empty 'name' field.";
|
|
if(!values.TryGetValue(Suggestions.SoftwareSuggestionApplier.FieldKind, out object k) ||
|
|
!TryExtractIntForValidation(k, out int kindValue) ||
|
|
!Enum.IsDefined(typeof(Marechai.Data.SoftwareKind), kindValue))
|
|
return "A new software suggestion must include a valid 'kind' field.";
|
|
string firstReleaseTitleKey = Suggestions.SoftwareSuggestionApplier.FirstReleaseScalarPrefix +
|
|
Suggestions.SoftwareReleaseSuggestionApplier.FieldTitle;
|
|
if(!values.TryGetValue(firstReleaseTitleKey, out object frt) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(frt)))
|
|
return "A new software suggestion must include a non-empty 'first_release_title' field (a software requires its first release at creation time).";
|
|
string firstReleasePublisherKey = Suggestions.SoftwareSuggestionApplier.FirstReleaseScalarPrefix +
|
|
Suggestions.SoftwareReleaseSuggestionApplier.FieldPublisherId;
|
|
if(!values.ContainsKey(firstReleasePublisherKey))
|
|
return "A new software suggestion must include a 'first_release_publisher_id' field referencing the first release's publisher.";
|
|
string firstReleasePlatformKey = Suggestions.SoftwareSuggestionApplier.FirstReleaseScalarPrefix +
|
|
Suggestions.SoftwareReleaseSuggestionApplier.FieldPlatformId;
|
|
if(!values.ContainsKey(firstReleasePlatformKey))
|
|
return "A new software suggestion must include a 'first_release_platform_id' field referencing the first release's platform.";
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.SoftwareVersion:
|
|
{
|
|
// A SoftwareVersion has no useful surface without a SoftwareRelease, so a
|
|
// brand-new SoftwareVersion suggestion MUST carry its first-release in the
|
|
// same submission. Validation order: software_id (parent FK) →
|
|
// version_string → first_release_title → first_release_publisher_id →
|
|
// first_release_platform_id. Matches the dialog's per-field
|
|
// defence-in-depth in priority order.
|
|
if(!values.ContainsKey(Suggestions.SoftwareVersionSuggestionApplier.FieldSoftwareId))
|
|
return "A new software version suggestion must include a 'software_id' field referencing the parent software.";
|
|
if(!values.TryGetValue(Suggestions.SoftwareVersionSuggestionApplier.FieldVersionString, out object vs) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(vs)))
|
|
return "A new software version suggestion must include a non-empty 'version_string' field.";
|
|
string firstReleaseTitleKey = Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix +
|
|
Suggestions.SoftwareReleaseSuggestionApplier.FieldTitle;
|
|
if(!values.TryGetValue(firstReleaseTitleKey, out object frt) ||
|
|
string.IsNullOrWhiteSpace(ExtractStringForValidation(frt)))
|
|
return "A new software version suggestion must include a non-empty 'first_release_title' field (a version requires its first release at creation time).";
|
|
string firstReleasePublisherKey = Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix +
|
|
Suggestions.SoftwareReleaseSuggestionApplier.FieldPublisherId;
|
|
if(!values.ContainsKey(firstReleasePublisherKey))
|
|
return "A new software version suggestion must include a 'first_release_publisher_id' field referencing the first release's publisher.";
|
|
string firstReleasePlatformKey = Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix +
|
|
Suggestions.SoftwareReleaseSuggestionApplier.FieldPlatformId;
|
|
if(!values.ContainsKey(firstReleasePlatformKey))
|
|
return "A new software version suggestion must include a 'first_release_platform_id' field referencing the first release's platform.";
|
|
return null;
|
|
}
|
|
default:
|
|
return $"Brand-new {type} suggestions are not supported.";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pull the per-entity display name from a suggested-values payload (e.g. the
|
|
/// <c>name</c> field for Company). Returns <c>null</c> when the payload doesn't
|
|
/// include a usable name.
|
|
/// </summary>
|
|
static string ExtractAdditionDisplayName(SuggestionEntityType type, Dictionary<string, object> values)
|
|
{
|
|
if(values is null) return null;
|
|
|
|
switch(type)
|
|
{
|
|
case SuggestionEntityType.Company:
|
|
{
|
|
if(values.TryGetValue(Suggestions.CompanySuggestionApplier.FieldName, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Machine:
|
|
{
|
|
if(values.TryGetValue(Suggestions.MachineSuggestionApplier.FieldName, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Magazine:
|
|
{
|
|
if(values.TryGetValue(Suggestions.MagazineSuggestionApplier.FieldTitle, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Book:
|
|
{
|
|
if(values.TryGetValue(Suggestions.BookSuggestionApplier.FieldTitle, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Document:
|
|
{
|
|
if(values.TryGetValue(Suggestions.DocumentSuggestionApplier.FieldTitle, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.MagazineIssue:
|
|
{
|
|
if(values.TryGetValue(Suggestions.MagazineIssueSuggestionApplier.FieldCaption, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Gpu:
|
|
{
|
|
if(values.TryGetValue(Suggestions.GpuSuggestionApplier.FieldName, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Processor:
|
|
{
|
|
if(values.TryGetValue(Suggestions.ProcessorSuggestionApplier.FieldName, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.SoundSynth:
|
|
{
|
|
if(values.TryGetValue(Suggestions.SoundSynthSuggestionApplier.FieldName, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Person:
|
|
{
|
|
// Combine name + surname for the queue display label. Fall back gracefully
|
|
// when only one is present (the validator will reject the suggestion later
|
|
// anyway, but we still want a sensible label in the meantime).
|
|
string namePart = values.TryGetValue(Suggestions.PersonSuggestionApplier.FieldName, out object nVal) ? ExtractStringForValidation(nVal)?.Trim() : null;
|
|
string surnamePart = values.TryGetValue(Suggestions.PersonSuggestionApplier.FieldSurname, out object sVal) ? ExtractStringForValidation(sVal)?.Trim() : null;
|
|
string combined = string.Join(" ", new[] { namePart, surnamePart }.Where(p => !string.IsNullOrEmpty(p)));
|
|
return string.IsNullOrWhiteSpace(combined) ? null : combined;
|
|
}
|
|
case SuggestionEntityType.SoftwareRelease:
|
|
{
|
|
if(values.TryGetValue(Suggestions.SoftwareReleaseSuggestionApplier.FieldTitle, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.Software:
|
|
{
|
|
// Display the software's own name in the queue. The first-release title is
|
|
// shown in the expanded admin diff via the prefixed first_release_title key.
|
|
if(values.TryGetValue(Suggestions.SoftwareSuggestionApplier.FieldName, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
case SuggestionEntityType.SoftwareVersion:
|
|
{
|
|
// Display the version string in the queue. The first-release title is
|
|
// shown in the expanded admin diff via the prefixed first_release_title key.
|
|
if(values.TryGetValue(Suggestions.SoftwareVersionSuggestionApplier.FieldVersionString, out object n))
|
|
{
|
|
string s = ExtractStringForValidation(n);
|
|
return string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
|
}
|
|
return null;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cross-check whether an entity of the given type already exists with the given
|
|
/// normalised name (lower-case, trimmed). Used by addition dedupe to redirect users
|
|
/// to the edit flow when they try to suggest a brand-new entity that's already in the
|
|
/// catalogue.
|
|
/// </summary>
|
|
async Task<bool> EntityWithNameExistsAsync(SuggestionEntityType type, string normalisedName)
|
|
{
|
|
if(string.IsNullOrEmpty(normalisedName)) return false;
|
|
|
|
switch(type)
|
|
{
|
|
case SuggestionEntityType.Company:
|
|
return await context.Companies.AsNoTracking()
|
|
.AnyAsync(c => c.Name != null && c.Name.ToLower() == normalisedName);
|
|
case SuggestionEntityType.Machine:
|
|
// Machine names are not globally unique (same name can legitimately exist under
|
|
// different companies, e.g. "Series 1" by multiple manufacturers). Dedupe on
|
|
// name only would over-block; rely on admin moderation to spot duplicates within
|
|
// a manufacturer instead.
|
|
return false;
|
|
case SuggestionEntityType.Magazine:
|
|
// Magazine titles repeat across decades, regions and languages (e.g. "BYTE",
|
|
// "Computer World"). Dedupe on title only would over-block; rely on admin
|
|
// moderation to spot true duplicates instead.
|
|
return false;
|
|
case SuggestionEntityType.Book:
|
|
// Book titles repeat across editions, languages and reprints (multiple
|
|
// distinct "Programming in C" books exist for different decades/authors).
|
|
// Dedupe on title only would over-block; rely on admin moderation to spot
|
|
// true duplicates instead.
|
|
return false;
|
|
case SuggestionEntityType.Document:
|
|
// Document titles repeat across editions, translations and reprints (e.g.
|
|
// "User's Manual" appears for many machines / decades). Dedupe on title only
|
|
// would over-block; rely on admin moderation to spot true duplicates instead.
|
|
return false;
|
|
case SuggestionEntityType.MagazineIssue:
|
|
// Issue captions repeat across years/months (e.g. "Issue 1", "January",
|
|
// "Annual") and even within a single magazine. Dedupe on caption only would
|
|
// over-block; rely on admin moderation to spot true duplicates instead.
|
|
return false;
|
|
case SuggestionEntityType.Gpu:
|
|
// GPU names legitimately repeat across die revisions, OEM rebrands and
|
|
// manufacturers (e.g. "GeForce 256" exists as multiple distinct die
|
|
// revisions). Dedupe on name only would over-block; rely on admin moderation
|
|
// to spot true duplicates instead.
|
|
return false;
|
|
case SuggestionEntityType.Processor:
|
|
// Processor names legitimately repeat across die revisions, OEM rebrands and
|
|
// manufacturers (e.g. "Z80" by Zilog vs Z80-compatible by NEC). Dedupe on
|
|
// name only would over-block; rely on admin moderation to spot true
|
|
// duplicates instead.
|
|
return false;
|
|
case SuggestionEntityType.SoundSynth:
|
|
// Sound synthesizer names legitimately repeat across die revisions, OEM
|
|
// rebrands and manufacturers (e.g. "AY-3-8910" by GI vs Yamaha as YM2149,
|
|
// "SID 6581" vs "SID 8580"). Dedupe on name only would over-block; rely on
|
|
// admin moderation to spot true duplicates instead.
|
|
return false;
|
|
case SuggestionEntityType.Person:
|
|
// Person names legitimately repeat across history, regions and eras (many
|
|
// distinct historical figures share names like "John Smith"). Dedupe on the
|
|
// first name alone would massively over-block; rely on admin moderation to
|
|
// spot true duplicates by combining name+surname+birth date+context.
|
|
return false;
|
|
case SuggestionEntityType.SoftwareRelease:
|
|
// Release titles legitimately repeat across regions, platforms and re-issues
|
|
// (the SAME software release "Doom" exists for many platforms/regions/years
|
|
// each as a distinct release). Dedupe on title only would massively
|
|
// over-block; rely on admin moderation to spot true duplicates instead.
|
|
return false;
|
|
case SuggestionEntityType.Software:
|
|
// Software names legitimately repeat across vendors, ports and unrelated
|
|
// products of the same name (e.g. "Solitaire" exists as countless distinct
|
|
// games over decades). Dedupe on name alone would massively over-block;
|
|
// rely on admin moderation + the mandatory accompanying first-release
|
|
// (publisher/platform/title) to disambiguate.
|
|
return false;
|
|
case SuggestionEntityType.SoftwareVersion:
|
|
// Version strings repeat constantly ("1.0", "2.0", "1.0.0") across every
|
|
// software ever written. Dedupe on version_string only would over-block;
|
|
// the per-(software, version_string) uniqueness check is implicit in the
|
|
// accepting admin's review of the parent software_id pseudo-field.
|
|
return false;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static string NormaliseName(string name) =>
|
|
string.IsNullOrWhiteSpace(name) ? string.Empty : name.Trim().ToLowerInvariant();
|
|
|
|
/// <summary>
|
|
/// Per-entity dispatch for creating a brand-new entity from an accepted suggestion.
|
|
/// Returns the new entity id (so the controller can patch <c>Suggestion.EntityId</c>) and
|
|
/// the actually-applied field set. Returns <c>(null, empty)</c> when creation can't
|
|
/// proceed (e.g. admin didn't tick the mandatory <c>name</c> field).
|
|
/// </summary>
|
|
async Task<(long? newId, HashSet<string> applied)> CreateNewEntityAsync(
|
|
SuggestionEntityType type,
|
|
Dictionary<string, object> suggested,
|
|
HashSet<string> accepted,
|
|
string creditedUserId)
|
|
{
|
|
switch(type)
|
|
{
|
|
case SuggestionEntityType.Company:
|
|
{
|
|
var (id, applied) = await Suggestions.CompanySuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId);
|
|
return (id, applied);
|
|
}
|
|
case SuggestionEntityType.Machine:
|
|
{
|
|
var (id, applied) = await Suggestions.MachineSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId);
|
|
return (id, applied);
|
|
}
|
|
case SuggestionEntityType.Magazine:
|
|
{
|
|
var (id, applied) = await Suggestions.MagazineSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId);
|
|
return (id, applied);
|
|
}
|
|
case SuggestionEntityType.Book:
|
|
{
|
|
var (id, applied) = await Suggestions.BookSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId, _assetRootPath);
|
|
return (id, applied);
|
|
}
|
|
case SuggestionEntityType.Document:
|
|
{
|
|
var (id, applied) = await Suggestions.DocumentSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId);
|
|
return (id, applied);
|
|
}
|
|
case SuggestionEntityType.MagazineIssue:
|
|
{
|
|
var (id, applied) = await Suggestions.MagazineIssueSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId, _assetRootPath);
|
|
return (id, applied);
|
|
}
|
|
case SuggestionEntityType.Gpu:
|
|
{
|
|
var (id, applied) = await Suggestions.GpuSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId);
|
|
return (id, applied);
|
|
}
|
|
case SuggestionEntityType.Processor:
|
|
{
|
|
var (id, applied) = await Suggestions.ProcessorSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId);
|
|
return (id, applied);
|
|
}
|
|
case SuggestionEntityType.SoundSynth:
|
|
{
|
|
var (id, applied) = await Suggestions.SoundSynthSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId);
|
|
return (id, applied);
|
|
}
|
|
case SuggestionEntityType.Person:
|
|
{
|
|
var (id, applied) = await Suggestions.PersonSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId, _assetRootPath);
|
|
return (id, applied);
|
|
}
|
|
case SuggestionEntityType.SoftwareRelease:
|
|
{
|
|
var (id, applied) = await Suggestions.SoftwareReleaseSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId);
|
|
// SoftwareRelease.Id is ulong; implicit cast to long? for the unified
|
|
// dispatch return tuple matches the Software ulong-PK precedent.
|
|
return ((long?)id, applied);
|
|
}
|
|
case SuggestionEntityType.Software:
|
|
{
|
|
// Software has no user-facing surface without a release, so this CreateAsync
|
|
// orchestrates BOTH a Software AND its first SoftwareRelease in an atomic
|
|
// EF transaction. The release fields travel on the wire prefixed with
|
|
// "first_release_" / "first_release."; the applier strips the prefixes,
|
|
// delegates to SoftwareReleaseSuggestionApplier.CreateAsync, then re-prefixes
|
|
// the applied keys back so the admin diff panel sees the original wire keys.
|
|
var (id, applied) = await Suggestions.SoftwareSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId);
|
|
// Software.Id is ulong; explicit cast to long? — implicit conversion from
|
|
// ulong? to long? doesn't exist in C# (per the SoftwareRelease port lesson).
|
|
return ((long?)id, applied);
|
|
}
|
|
case SuggestionEntityType.SoftwareVersion:
|
|
{
|
|
// A SoftwareVersion has no useful surface without a SoftwareRelease, so
|
|
// this CreateAsync orchestrates BOTH a SoftwareVersion AND its first
|
|
// SoftwareRelease in an atomic EF transaction. Same dual-prefix wire shape
|
|
// as the Software port; both software_id (parent FK on the release) and
|
|
// software_version_id (chain from release back to the new version) are
|
|
// injected by the applier from the freshly-minted version row, not the
|
|
// wire payload.
|
|
var (id, applied) = await Suggestions.SoftwareVersionSuggestionApplier.CreateAsync(
|
|
context, suggested, accepted, creditedUserId);
|
|
// SoftwareVersion.Id is ulong; explicit cast to long? — implicit conversion
|
|
// from ulong? to long? doesn't exist in C#.
|
|
return ((long?)id, applied);
|
|
}
|
|
default:
|
|
throw new NotImplementedException($"Creating a new {type} from a suggestion is not implemented yet.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Optional secondary label for the entity (e.g. <c>"(Spanish description)"</c>) used
|
|
/// by the queue / review dialog to disambiguate per-subkey suggestions. Returns
|
|
/// <c>null</c> when the entity type doesn't use a Subkey.
|
|
/// </summary>
|
|
async Task<string> GetEntitySecondaryLabelAsync(SuggestionEntityType type, string subkey)
|
|
{
|
|
if(string.IsNullOrEmpty(subkey)) return null;
|
|
|
|
switch(type)
|
|
{
|
|
case SuggestionEntityType.CompanyDescription:
|
|
{
|
|
string langName = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == subkey)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return $"({langName ?? subkey} description)";
|
|
}
|
|
case SuggestionEntityType.MachineDescription:
|
|
{
|
|
string langName = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == subkey)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return $"({langName ?? subkey} description)";
|
|
}
|
|
case SuggestionEntityType.BookSynopsis:
|
|
{
|
|
string langName = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == subkey)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return $"({langName ?? subkey} synopsis)";
|
|
}
|
|
case SuggestionEntityType.DocumentSynopsis:
|
|
{
|
|
string langName = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == subkey)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return $"({langName ?? subkey} synopsis)";
|
|
}
|
|
case SuggestionEntityType.MagazineSynopsis:
|
|
{
|
|
string langName = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == subkey)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return $"({langName ?? subkey} synopsis)";
|
|
}
|
|
case SuggestionEntityType.GpuDescription:
|
|
{
|
|
string langName = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == subkey)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return $"({langName ?? subkey} description)";
|
|
}
|
|
case SuggestionEntityType.ProcessorDescription:
|
|
{
|
|
string langName = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == subkey)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return $"({langName ?? subkey} description)";
|
|
}
|
|
case SuggestionEntityType.SoundSynthDescription:
|
|
{
|
|
string langName = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == subkey)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return $"({langName ?? subkey} description)";
|
|
}
|
|
case SuggestionEntityType.PersonDescription:
|
|
{
|
|
string langName = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == subkey)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return $"({langName ?? subkey} biography)";
|
|
}
|
|
case SuggestionEntityType.SoftwareDescription:
|
|
{
|
|
string langName = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == subkey)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return $"({langName ?? subkey} description)";
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reproject the raw payload dictionary (whose values are <c>object</c>) into a
|
|
/// <c>JsonElement</c>-keyed shape so the FK-id extraction in
|
|
/// <see cref="ResolveLabelsAsync" /> can reuse the same reading code as the client diff
|
|
/// panel.
|
|
/// </summary>
|
|
static Dictionary<string, JsonElement> ParseValuesForLabels(Dictionary<string, object> values)
|
|
{
|
|
var result = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
|
if(values is null) return result;
|
|
|
|
foreach(KeyValuePair<string, object> kv in values)
|
|
{
|
|
if(kv.Value is null) continue;
|
|
string json = JsonSerializer.Serialize(kv.Value);
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
result[kv.Key] = doc.RootElement.Clone();
|
|
}
|
|
catch
|
|
{
|
|
// Skip values that don't round-trip through JsonElement.
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve foreign-key field values to display labels for the given entity type. Returns
|
|
/// a dictionary mapping field-name → label (e.g. <c>"country_id" → "Spain"</c>) for any
|
|
/// FK fields with a recognised id; non-FK fields and unresolved ids are omitted. The
|
|
/// diff panel falls back to <c>SuggestionMetadata.FormatDisplayValue</c> for missing
|
|
/// entries. The <paramref name="entityId" /> is used by entity types with junction
|
|
/// operations (Machine) to scope row lookups; junction-add labels resolve from the
|
|
/// suggested payload's id field. Junction-remove labels are resolved separately by
|
|
/// <see cref="ResolveMachineRemoveLabelsAsync" /> so they land in the current-side
|
|
/// label map.
|
|
/// </summary>
|
|
async Task<Dictionary<string, string>> ResolveLabelsAsync(SuggestionEntityType type,
|
|
long? entityId,
|
|
Dictionary<string, JsonElement> values)
|
|
{
|
|
var labels = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
if(values is null || values.Count == 0) return labels;
|
|
|
|
switch(type)
|
|
{
|
|
case SuggestionEntityType.Company:
|
|
{
|
|
if(values.TryGetValue(Suggestions.CompanySuggestionApplier.FieldCountryId, out JsonElement cv))
|
|
{
|
|
short? id = JsonElementToShort(cv);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Iso31661Numeric.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.CompanySuggestionApplier.FieldCountryId] = name;
|
|
}
|
|
}
|
|
|
|
if(values.TryGetValue(Suggestions.CompanySuggestionApplier.FieldSoldToId, out JsonElement sv))
|
|
{
|
|
int? id = JsonElementToInt(sv);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.CompanySuggestionApplier.FieldSoldToId] = name;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case SuggestionEntityType.Machine:
|
|
{
|
|
await ResolveMachineLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.Book:
|
|
{
|
|
await ResolveBookLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.Document:
|
|
{
|
|
await ResolveDocumentLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.Magazine:
|
|
{
|
|
await ResolveMagazineLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.MagazineIssue:
|
|
{
|
|
await ResolveMagazineIssueLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.Gpu:
|
|
{
|
|
await ResolveGpuLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.Processor:
|
|
{
|
|
await ResolveProcessorLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.SoundSynth:
|
|
{
|
|
await ResolveSoundSynthLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.Person:
|
|
{
|
|
await ResolvePersonLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.Software:
|
|
{
|
|
await ResolveSoftwareLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.SoftwareRelease:
|
|
{
|
|
await ResolveSoftwareReleaseLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.SoftwareVersion:
|
|
{
|
|
await ResolveSoftwareVersionLabelsAsync(entityId, values, labels);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.GpuPhoto:
|
|
{
|
|
if(values.TryGetValue(Suggestions.GpuPhotoSuggestionApplier.FieldLicenseId, out JsonElement lj))
|
|
{
|
|
int? id = JsonElementToInt(lj);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Licenses.AsNoTracking()
|
|
.Where(l => l.Id == id.Value)
|
|
.Select(l => l.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.GpuPhotoSuggestionApplier.FieldLicenseId] = name;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case SuggestionEntityType.ProcessorPhoto:
|
|
{
|
|
if(values.TryGetValue(Suggestions.ProcessorPhotoSuggestionApplier.FieldLicenseId, out JsonElement lj))
|
|
{
|
|
int? id = JsonElementToInt(lj);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Licenses.AsNoTracking()
|
|
.Where(l => l.Id == id.Value)
|
|
.Select(l => l.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.ProcessorPhotoSuggestionApplier.FieldLicenseId] = name;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case SuggestionEntityType.SoundSynthPhoto:
|
|
{
|
|
if(values.TryGetValue(Suggestions.SoundSynthPhotoSuggestionApplier.FieldLicenseId, out JsonElement lj))
|
|
{
|
|
int? id = JsonElementToInt(lj);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Licenses.AsNoTracking()
|
|
.Where(l => l.Id == id.Value)
|
|
.Select(l => l.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoundSynthPhotoSuggestionApplier.FieldLicenseId] = name;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case SuggestionEntityType.MachinePhoto:
|
|
{
|
|
if(values.TryGetValue(Suggestions.MachinePhotoSuggestionApplier.FieldLicenseId, out JsonElement lj))
|
|
{
|
|
int? id = JsonElementToInt(lj);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Licenses.AsNoTracking()
|
|
.Where(l => l.Id == id.Value)
|
|
.Select(l => l.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.MachinePhotoSuggestionApplier.FieldLicenseId] = name;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case SuggestionEntityType.SoftwarePromoArt:
|
|
{
|
|
// Echo the free-text group name as its own label so the diff panel can
|
|
// render it directly. No DB lookup needed because the value IS the label.
|
|
if(values.TryGetValue(Suggestions.SoftwarePromoArtSuggestionApplier.FieldGroupName,
|
|
out JsonElement gj) && gj.ValueKind == JsonValueKind.String)
|
|
{
|
|
string name = gj.GetString();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoftwarePromoArtSuggestionApplier.FieldGroupName] = name;
|
|
}
|
|
break;
|
|
}
|
|
case SuggestionEntityType.SoftwareScreenshot:
|
|
{
|
|
// Resolve the suggestion-level platform_id to its display Name so the diff
|
|
// panel can render "Platform: Windows" instead of "Platform: #5".
|
|
if(values.TryGetValue(Suggestions.SoftwareScreenshotSuggestionApplier.FieldPlatformId,
|
|
out JsonElement pj))
|
|
{
|
|
ulong pid = 0;
|
|
if(pj.ValueKind == JsonValueKind.Number && pj.TryGetUInt64(out ulong puv)) pid = puv;
|
|
else if(pj.ValueKind == JsonValueKind.String &&
|
|
ulong.TryParse(pj.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture,
|
|
out ulong psv)) pid = psv;
|
|
|
|
if(pid > 0)
|
|
{
|
|
string name = await context.SoftwarePlatforms.AsNoTracking()
|
|
.Where(p => p.Id == pid)
|
|
.Select(p => p.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoftwareScreenshotSuggestionApplier.FieldPlatformId] = name;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return labels;
|
|
}
|
|
|
|
static int? JsonElementToInt(JsonElement e) => e.ValueKind switch
|
|
{
|
|
JsonValueKind.Number => e.TryGetInt32(out int i) ? i : null,
|
|
JsonValueKind.String => int.TryParse(e.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int p) ? p : null,
|
|
_ => null
|
|
};
|
|
|
|
static long? JsonElementToLong(JsonElement e) => e.ValueKind switch
|
|
{
|
|
JsonValueKind.Number => e.TryGetInt64(out long i) ? i : null,
|
|
JsonValueKind.String => long.TryParse(e.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long p) ? p : null,
|
|
_ => null
|
|
};
|
|
|
|
static double? JsonElementToDouble(JsonElement e) => e.ValueKind switch
|
|
{
|
|
JsonValueKind.Number => e.TryGetDouble(out double i) ? i : null,
|
|
JsonValueKind.String => double.TryParse(e.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double p) ? p : null,
|
|
_ => null
|
|
};
|
|
|
|
static short? JsonElementToShort(JsonElement e)
|
|
{
|
|
int? i = JsonElementToInt(e);
|
|
return i.HasValue && i.Value is >= short.MinValue and <= short.MaxValue ? (short)i.Value : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build readable labels for the heterogeneous payload of a Machine suggestion. Handles
|
|
/// scalar FK fields (<c>company_id</c>, <c>family_id</c>) plus junction-add operation
|
|
/// keys (<c><group>.add.<uuid></c> resolved from the suggested payload's id
|
|
/// field). Junction-remove labels are populated separately by
|
|
/// <see cref="ResolveMachineRemoveLabelsAsync" /> directly into the current-side label
|
|
/// map so the diff panel renders them in the strikethrough cell.
|
|
/// </summary>
|
|
async Task ResolveMachineLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
// ---- Scalar FK labels -----------------------------------------------------
|
|
if(values.TryGetValue(Suggestions.MachineSuggestionApplier.FieldCompanyId, out JsonElement compE))
|
|
{
|
|
int? id = JsonElementToInt(compE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.MachineSuggestionApplier.FieldCompanyId] = name;
|
|
}
|
|
}
|
|
|
|
if(values.TryGetValue(Suggestions.MachineSuggestionApplier.FieldFamilyId, out JsonElement famE))
|
|
{
|
|
int? id = JsonElementToInt(famE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.MachineFamilies.AsNoTracking()
|
|
.Where(f => f.Id == id.Value)
|
|
.Select(f => f.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.MachineSuggestionApplier.FieldFamilyId] = name;
|
|
}
|
|
}
|
|
|
|
// ---- Junction-add labels (resolved from the suggested payload's id field) -----
|
|
// These do NOT depend on the parent entity existing — they look up the linked entity
|
|
// (GPU, Processor, etc.) directly from the payload's id field, so they work for both
|
|
// edit-mode (entityId.HasValue) AND addition-mode (entityId is null) suggestions.
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(!Suggestions.MachineSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out _))
|
|
continue;
|
|
if(op != "add" || kv.Value.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
string label = await BuildAddLabelAsync(group, kv.Value);
|
|
if(!string.IsNullOrEmpty(label)) labels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve readable labels for every Machine junction-remove operation in the suggested
|
|
/// payload by looking up the existing junction row in the database. The resolved labels
|
|
/// are written to <paramref name="currentLabels" /> so the diff panel renders them in
|
|
/// the strikethrough cell of the JunctionRemove row. Iterates the RAW
|
|
/// <c>SuggestedValues</c> dict (not the JsonElement-parsed view) because remove ops
|
|
/// carry a null value and <see cref="ParseValuesForLabels" /> drops null entries.
|
|
/// </summary>
|
|
async Task ResolveMachineRemoveLabelsAsync(int machineId,
|
|
Dictionary<string, object> rawSuggested,
|
|
Dictionary<string, string> currentLabels)
|
|
{
|
|
foreach(KeyValuePair<string, object> kv in rawSuggested)
|
|
{
|
|
if(!Suggestions.MachineSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out string token))
|
|
continue;
|
|
if(op != "remove") continue;
|
|
if(!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long rowId)) continue;
|
|
|
|
string label = await BuildRemoveLabelAsync(group, machineId, rowId);
|
|
if(!string.IsNullOrEmpty(label)) currentLabels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.MachineSuggestionApplier.GroupGpus:
|
|
{
|
|
int? id = ReadIntField(payload, "gpu_id");
|
|
if(!id.HasValue) return null;
|
|
var row = await context.Gpus.AsNoTracking()
|
|
.Where(g => g.Id == id.Value)
|
|
.Select(g => new { g.Name, CompanyName = g.Company.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row is null ? $"GPU #{id.Value}" : $"{row.Name} ({row.CompanyName})";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupProcessors:
|
|
{
|
|
int? id = ReadIntField(payload, "processor_id");
|
|
if(!id.HasValue) return null;
|
|
double? sp = ReadDoubleField(payload, "speed");
|
|
var row = await context.Processors.AsNoTracking()
|
|
.Where(p => p.Id == id.Value)
|
|
.Select(p => new { p.Name, CompanyName = p.Company.Name })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return $"Processor #{id.Value}";
|
|
return sp is > 0 ? $"{row.Name} ({row.CompanyName}) @ {sp} MHz" : $"{row.Name} ({row.CompanyName})";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupSoundSynths:
|
|
{
|
|
int? id = ReadIntField(payload, "sound_synth_id");
|
|
if(!id.HasValue) return null;
|
|
var row = await context.SoundSynths.AsNoTracking()
|
|
.Where(s => s.Id == id.Value)
|
|
.Select(s => new { s.Name, CompanyName = s.Company.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row is null ? $"Sound synth #{id.Value}" : $"{row.Name} ({row.CompanyName})";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupScreens:
|
|
{
|
|
int? id = ReadIntField(payload, "screen_id");
|
|
if(!id.HasValue) return null;
|
|
var row = await context.Screens.AsNoTracking()
|
|
.Where(s => s.Id == id.Value)
|
|
.Select(s => new { s.Diagonal, s.Type })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return $"Screen #{id.Value}";
|
|
return string.IsNullOrWhiteSpace(row.Type) ? $"{row.Diagonal}\"" : $"{row.Diagonal}\" {row.Type}";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupMemory:
|
|
{
|
|
int? type = ReadIntField(payload, "type");
|
|
int? usage = ReadIntField(payload, "usage");
|
|
long? size = ReadLongField(payload, "size");
|
|
double? speed = ReadDoubleField(payload, "speed");
|
|
string typeName = type.HasValue && Enum.IsDefined(typeof(MemoryType), type.Value)
|
|
? ((MemoryType)type.Value).ToString()
|
|
: "Unknown";
|
|
string usageName = usage.HasValue && Enum.IsDefined(typeof(MemoryUsage), usage.Value)
|
|
? ((MemoryUsage)usage.Value).ToString()
|
|
: "Unknown";
|
|
string sizeStr = size.HasValue ? FormatBytesShort(size.Value) : "Unknown";
|
|
string speedStr = speed.HasValue ? $" @ {speed} MHz" : string.Empty;
|
|
return $"{sizeStr} {typeName} ({usageName}){speedStr}";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupStorage:
|
|
{
|
|
int? type = ReadIntField(payload, "type");
|
|
int? iface = ReadIntField(payload, "interface");
|
|
long? capacity = ReadLongField(payload, "capacity");
|
|
string typeName = type.HasValue && Enum.IsDefined(typeof(StorageType), type.Value)
|
|
? ((StorageType)type.Value).ToString()
|
|
: "Unknown";
|
|
string ifaceName = iface.HasValue && Enum.IsDefined(typeof(StorageInterface), iface.Value)
|
|
? ((StorageInterface)iface.Value).ToString()
|
|
: "Unknown";
|
|
string capStr = capacity.HasValue ? $" - {FormatBytesShort(capacity.Value)}" : string.Empty;
|
|
return $"{typeName} ({ifaceName}){capStr}";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupSoftwarePlatforms:
|
|
{
|
|
long? rawId = ReadLongField(payload, "software_platform_id");
|
|
if(!rawId.HasValue || rawId.Value < 0) return null;
|
|
ulong id = (ulong)rawId.Value;
|
|
string name = await context.SoftwarePlatforms.AsNoTracking()
|
|
.Where(p => p.Id == id)
|
|
.Select(p => p.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Software platform #{id}" : name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildRemoveLabelAsync(string group, int machineId, long rowId)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.MachineSuggestionApplier.GroupGpus:
|
|
{
|
|
var row = await context.GpusByMachine.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MachineId == machineId)
|
|
.Select(r => new { r.Gpu.Name, CompanyName = r.Gpu.Company.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row is null ? null : $"{row.Name} ({row.CompanyName})";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupProcessors:
|
|
{
|
|
var row = await context.ProcessorsByMachine.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MachineId == machineId)
|
|
.Select(r => new { r.Processor.Name, CompanyName = r.Processor.Company.Name, r.Speed })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return row.Speed is > 0 ? $"{row.Name} ({row.CompanyName}) @ {row.Speed} MHz" : $"{row.Name} ({row.CompanyName})";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupSoundSynths:
|
|
{
|
|
var row = await context.SoundByMachine.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MachineId == machineId)
|
|
.Select(r => new { r.SoundSynth.Name, CompanyName = r.SoundSynth.Company.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row is null ? null : $"{row.Name} ({row.CompanyName})";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupScreens:
|
|
{
|
|
var row = await context.ScreensByMachine.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MachineId == machineId)
|
|
.Select(r => new { r.Screen.Diagonal, r.Screen.Type })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return string.IsNullOrWhiteSpace(row.Type) ? $"{row.Diagonal}\"" : $"{row.Diagonal}\" {row.Type}";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupMemory:
|
|
{
|
|
var row = await context.MemoryByMachine.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MachineId == machineId)
|
|
.Select(r => new { r.Type, r.Usage, r.Size, r.Speed })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
string sizeStr = row.Size.HasValue ? FormatBytesShort(row.Size.Value) : "Unknown";
|
|
string speedStr = row.Speed.HasValue ? $" @ {row.Speed} MHz" : string.Empty;
|
|
return $"{sizeStr} {row.Type} ({row.Usage}){speedStr}";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupStorage:
|
|
{
|
|
var row = await context.StorageByMachine.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MachineId == machineId)
|
|
.Select(r => new { r.Type, r.Interface, r.Capacity })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
string capStr = row.Capacity.HasValue ? $" - {FormatBytesShort(row.Capacity.Value)}" : string.Empty;
|
|
return $"{row.Type} ({row.Interface}){capStr}";
|
|
}
|
|
case Suggestions.MachineSuggestionApplier.GroupSoftwarePlatforms:
|
|
{
|
|
var row = await context.SoftwarePlatformsByMachine.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MachineId == machineId)
|
|
.Select(r => new { Name = r.SoftwarePlatform.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row is null ? null : row.Name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static int? ReadIntField(JsonElement obj, string key) =>
|
|
obj.TryGetProperty(key, out JsonElement v) ? JsonElementToInt(v) : null;
|
|
|
|
static long? ReadLongField(JsonElement obj, string key) =>
|
|
obj.TryGetProperty(key, out JsonElement v) ? JsonElementToLong(v) : null;
|
|
|
|
static double? ReadDoubleField(JsonElement obj, string key) =>
|
|
obj.TryGetProperty(key, out JsonElement v) ? JsonElementToDouble(v) : null;
|
|
|
|
static string FormatBytesShort(long bytes) => bytes switch
|
|
{
|
|
>= 1073741824 => $"{bytes / 1073741824.0:F1} GiB",
|
|
>= 1048576 => $"{bytes / 1048576.0:F1} MiB",
|
|
>= 1024 => $"{bytes / 1024.0:F1} KiB",
|
|
_ => $"{bytes} B"
|
|
};
|
|
|
|
static string ReadStringField(JsonElement obj, string key) =>
|
|
obj.TryGetProperty(key, out JsonElement v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
|
|
|
// ─────────────── Book entity-edit label resolution (scalars + junction ops) ───────────────
|
|
|
|
/// <summary>
|
|
/// Build readable labels for the heterogeneous payload of a Book suggestion. Handles
|
|
/// scalar FK fields (<c>country_id</c>, <c>previous_id</c>, <c>source_id</c>) plus
|
|
/// junction-add operation keys (<c><group>.add.<uuid></c> resolved from the
|
|
/// suggested payload's id field). Junction-remove labels are populated separately by
|
|
/// <see cref="ResolveBookRemoveLabelsAsync" /> directly into the current-side label
|
|
/// map so the diff panel renders them in the strikethrough cell.
|
|
/// </summary>
|
|
async Task ResolveBookLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
// ---- Scalar FK labels -----------------------------------------------------
|
|
if(values.TryGetValue(Suggestions.BookSuggestionApplier.FieldCountryId, out JsonElement countryE))
|
|
{
|
|
short? id = JsonElementToShort(countryE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Iso31661Numeric.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.BookSuggestionApplier.FieldCountryId] = name;
|
|
}
|
|
}
|
|
|
|
if(values.TryGetValue(Suggestions.BookSuggestionApplier.FieldPreviousId, out JsonElement prevE))
|
|
{
|
|
long? id = JsonElementToLong(prevE);
|
|
if(id.HasValue)
|
|
{
|
|
string title = await context.Books.AsNoTracking()
|
|
.Where(b => b.Id == id.Value)
|
|
.Select(b => b.Title)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(title))
|
|
labels[Suggestions.BookSuggestionApplier.FieldPreviousId] = title;
|
|
}
|
|
}
|
|
|
|
if(values.TryGetValue(Suggestions.BookSuggestionApplier.FieldSourceId, out JsonElement srcE))
|
|
{
|
|
long? id = JsonElementToLong(srcE);
|
|
if(id.HasValue)
|
|
{
|
|
string title = await context.Books.AsNoTracking()
|
|
.Where(b => b.Id == id.Value)
|
|
.Select(b => b.Title)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(title))
|
|
labels[Suggestions.BookSuggestionApplier.FieldSourceId] = title;
|
|
}
|
|
}
|
|
|
|
// ---- Junction-add labels (resolved from the suggested payload's id field) -----
|
|
// NB: junction-add labels do NOT need the parent entityId — they look up the
|
|
// linked entity (Person, Company, Machine, MachineFamily) directly from the
|
|
// payload's id field. Removing the previous `if(!entityId.HasValue) return;`
|
|
// guard so addition-mode (entityId=null) suggestions also get readable labels in
|
|
// the diff panel.
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(!Suggestions.BookSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out _))
|
|
continue;
|
|
if(op != "add" || kv.Value.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
string label = await BuildBookAddLabelAsync(group, kv.Value);
|
|
if(!string.IsNullOrEmpty(label)) labels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve readable labels for every Book junction-remove operation in the suggested
|
|
/// payload by looking up the existing junction row in the database. The resolved labels
|
|
/// are written to <paramref name="currentLabels" /> so the diff panel renders them in
|
|
/// the strikethrough cell of the JunctionRemove row. Iterates the RAW
|
|
/// <c>SuggestedValues</c> dict (not the JsonElement-parsed view) because remove ops
|
|
/// carry a null value and <see cref="ParseValuesForLabels" /> drops null entries.
|
|
/// </summary>
|
|
async Task ResolveBookRemoveLabelsAsync(long bookId,
|
|
Dictionary<string, object> rawSuggested,
|
|
Dictionary<string, string> currentLabels)
|
|
{
|
|
foreach(KeyValuePair<string, object> kv in rawSuggested)
|
|
{
|
|
if(!Suggestions.BookSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out string token))
|
|
continue;
|
|
if(op != "remove") continue;
|
|
if(!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long rowId)) continue;
|
|
|
|
string label = await BuildBookRemoveLabelAsync(group, bookId, rowId);
|
|
if(!string.IsNullOrEmpty(label)) currentLabels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildBookAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.BookSuggestionApplier.GroupPeople:
|
|
{
|
|
int? id = ReadIntField(payload, "person_id");
|
|
string roleId = ReadStringField(payload, "role_id");
|
|
if(!id.HasValue) return null;
|
|
var row = await context.People.AsNoTracking()
|
|
.Where(p => p.Id == id.Value)
|
|
.Select(p => new { Display = p.DisplayName ?? p.Alias ?? (p.Name + " " + p.Surname) })
|
|
.FirstOrDefaultAsync();
|
|
string display = row is null ? $"Person #{id.Value}" : row.Display;
|
|
if(!string.IsNullOrEmpty(roleId))
|
|
{
|
|
string roleName = await context.DocumentRoles.AsNoTracking()
|
|
.Where(r => r.Id == roleId)
|
|
.Select(r => r.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(roleName))
|
|
display = $"{display} ({roleName})";
|
|
}
|
|
return display;
|
|
}
|
|
case Suggestions.BookSuggestionApplier.GroupCompanies:
|
|
{
|
|
int? id = ReadIntField(payload, "company_id");
|
|
string roleId = ReadStringField(payload, "role_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
string display = string.IsNullOrEmpty(name) ? $"Company #{id.Value}" : name;
|
|
if(!string.IsNullOrEmpty(roleId))
|
|
{
|
|
string roleName = await context.DocumentRoles.AsNoTracking()
|
|
.Where(r => r.Id == roleId)
|
|
.Select(r => r.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(roleName))
|
|
display = $"{display} ({roleName})";
|
|
}
|
|
return display;
|
|
}
|
|
case Suggestions.BookSuggestionApplier.GroupMachines:
|
|
{
|
|
int? id = ReadIntField(payload, "machine_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.Machines.AsNoTracking()
|
|
.Where(m => m.Id == id.Value)
|
|
.Select(m => m.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Machine #{id.Value}" : name;
|
|
}
|
|
case Suggestions.BookSuggestionApplier.GroupMachineFamilies:
|
|
{
|
|
int? id = ReadIntField(payload, "machine_family_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.MachineFamilies.AsNoTracking()
|
|
.Where(f => f.Id == id.Value)
|
|
.Select(f => f.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Machine family #{id.Value}" : name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildBookRemoveLabelAsync(string group, long bookId, long rowId)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.BookSuggestionApplier.GroupPeople:
|
|
{
|
|
var row = await context.PeopleByBooks.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.BookId == bookId)
|
|
.Select(r => new
|
|
{
|
|
Display = r.Person.DisplayName ?? r.Person.Alias ?? (r.Person.Name + " " + r.Person.Surname),
|
|
RoleName = r.Role.Name
|
|
})
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return string.IsNullOrEmpty(row.RoleName) ? row.Display : $"{row.Display} ({row.RoleName})";
|
|
}
|
|
case Suggestions.BookSuggestionApplier.GroupCompanies:
|
|
{
|
|
var row = await context.CompaniesByBooks.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.BookId == bookId)
|
|
.Select(r => new { r.Company.Name, RoleName = r.Role.Name })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return string.IsNullOrEmpty(row.RoleName) ? row.Name : $"{row.Name} ({row.RoleName})";
|
|
}
|
|
case Suggestions.BookSuggestionApplier.GroupMachines:
|
|
{
|
|
var row = await context.BooksByMachines.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.BookId == bookId)
|
|
.Select(r => new { r.Machine.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row?.Name;
|
|
}
|
|
case Suggestions.BookSuggestionApplier.GroupMachineFamilies:
|
|
{
|
|
var row = await context.BooksByMachineFamilies.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.BookId == bookId)
|
|
.Select(r => new { r.MachineFamily.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row?.Name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build readable labels for the heterogeneous payload of a Document suggestion. Handles
|
|
/// the scalar FK field (<c>country_id</c>) plus junction-add operation keys
|
|
/// (<c><group>.add.<uuid></c> resolved from the suggested payload's id field).
|
|
/// Junction-remove labels are populated separately by
|
|
/// <see cref="ResolveDocumentRemoveLabelsAsync" /> directly into the current-side label
|
|
/// map so the diff panel renders them in the strikethrough cell.
|
|
/// </summary>
|
|
async Task ResolveDocumentLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
// ---- Scalar FK label ----------------------------------------------------------
|
|
if(values.TryGetValue(Suggestions.DocumentSuggestionApplier.FieldCountryId, out JsonElement countryE))
|
|
{
|
|
short? id = JsonElementToShort(countryE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Iso31661Numeric.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.DocumentSuggestionApplier.FieldCountryId] = name;
|
|
}
|
|
}
|
|
|
|
// NOTE: do NOT early-return on `!entityId.HasValue`. Junction-add labels look up
|
|
// the linked entity (Person/Company/Machine/MachineFamily) directly from the
|
|
// suggested payload's id field, so they're resolvable even in addition mode
|
|
// (entityId == null). Junction-remove labels DO need a parent id and are gated
|
|
// separately at the call site of ResolveDocumentRemoveLabelsAsync.
|
|
|
|
// ---- Junction-add labels (resolved from the suggested payload's id field) -----
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(!Suggestions.DocumentSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out _))
|
|
continue;
|
|
if(op != "add" || kv.Value.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
string label = await BuildDocumentAddLabelAsync(group, kv.Value);
|
|
if(!string.IsNullOrEmpty(label)) labels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve readable labels for every Document junction-remove operation in the
|
|
/// suggested payload by looking up the existing junction row in the database. The
|
|
/// resolved labels are written to <paramref name="currentLabels" /> so the diff panel
|
|
/// renders them in the strikethrough cell of the JunctionRemove row. Iterates the RAW
|
|
/// <c>SuggestedValues</c> dict (not the JsonElement-parsed view) because remove ops
|
|
/// carry a null value and <see cref="ParseValuesForLabels" /> drops null entries.
|
|
/// </summary>
|
|
async Task ResolveDocumentRemoveLabelsAsync(long documentId,
|
|
Dictionary<string, object> rawSuggested,
|
|
Dictionary<string, string> currentLabels)
|
|
{
|
|
foreach(KeyValuePair<string, object> kv in rawSuggested)
|
|
{
|
|
if(!Suggestions.DocumentSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out string token))
|
|
continue;
|
|
if(op != "remove") continue;
|
|
if(!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long rowId)) continue;
|
|
|
|
string label = await BuildDocumentRemoveLabelAsync(group, documentId, rowId);
|
|
if(!string.IsNullOrEmpty(label)) currentLabels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildDocumentAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.DocumentSuggestionApplier.GroupPeople:
|
|
{
|
|
int? id = ReadIntField(payload, "person_id");
|
|
string roleId = ReadStringField(payload, "role_id");
|
|
if(!id.HasValue) return null;
|
|
var row = await context.People.AsNoTracking()
|
|
.Where(p => p.Id == id.Value)
|
|
.Select(p => new { Display = p.DisplayName ?? p.Alias ?? (p.Name + " " + p.Surname) })
|
|
.FirstOrDefaultAsync();
|
|
string display = row is null ? $"Person #{id.Value}" : row.Display;
|
|
if(!string.IsNullOrEmpty(roleId))
|
|
{
|
|
string roleName = await context.DocumentRoles.AsNoTracking()
|
|
.Where(r => r.Id == roleId)
|
|
.Select(r => r.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(roleName))
|
|
display = $"{display} ({roleName})";
|
|
}
|
|
return display;
|
|
}
|
|
case Suggestions.DocumentSuggestionApplier.GroupCompanies:
|
|
{
|
|
int? id = ReadIntField(payload, "company_id");
|
|
string roleId = ReadStringField(payload, "role_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
string display = string.IsNullOrEmpty(name) ? $"Company #{id.Value}" : name;
|
|
if(!string.IsNullOrEmpty(roleId))
|
|
{
|
|
string roleName = await context.DocumentRoles.AsNoTracking()
|
|
.Where(r => r.Id == roleId)
|
|
.Select(r => r.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(roleName))
|
|
display = $"{display} ({roleName})";
|
|
}
|
|
return display;
|
|
}
|
|
case Suggestions.DocumentSuggestionApplier.GroupMachines:
|
|
{
|
|
int? id = ReadIntField(payload, "machine_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.Machines.AsNoTracking()
|
|
.Where(m => m.Id == id.Value)
|
|
.Select(m => m.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Machine #{id.Value}" : name;
|
|
}
|
|
case Suggestions.DocumentSuggestionApplier.GroupMachineFamilies:
|
|
{
|
|
int? id = ReadIntField(payload, "machine_family_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.MachineFamilies.AsNoTracking()
|
|
.Where(f => f.Id == id.Value)
|
|
.Select(f => f.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Machine family #{id.Value}" : name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildDocumentRemoveLabelAsync(string group, long documentId, long rowId)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.DocumentSuggestionApplier.GroupPeople:
|
|
{
|
|
var row = await context.PeopleByDocuments.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.DocumentId == documentId)
|
|
.Select(r => new
|
|
{
|
|
Display = r.Person.DisplayName ?? r.Person.Alias ?? (r.Person.Name + " " + r.Person.Surname),
|
|
RoleName = r.Role.Name
|
|
})
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return string.IsNullOrEmpty(row.RoleName) ? row.Display : $"{row.Display} ({row.RoleName})";
|
|
}
|
|
case Suggestions.DocumentSuggestionApplier.GroupCompanies:
|
|
{
|
|
var row = await context.CompaniesByDocuments.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.DocumentId == documentId)
|
|
.Select(r => new { r.Company.Name, RoleName = r.Role.Name })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return string.IsNullOrEmpty(row.RoleName) ? row.Name : $"{row.Name} ({row.RoleName})";
|
|
}
|
|
case Suggestions.DocumentSuggestionApplier.GroupMachines:
|
|
{
|
|
var row = await context.DocumentsByMachines.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.DocumentId == documentId)
|
|
.Select(r => new { r.Machine.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row?.Name;
|
|
}
|
|
case Suggestions.DocumentSuggestionApplier.GroupMachineFamilies:
|
|
{
|
|
var row = await context.DocumentsByMachineFamilies.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.DocumentId == documentId)
|
|
.Select(r => new { r.MachineFamily.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row?.Name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build readable labels for the heterogeneous payload of a Magazine suggestion. Handles
|
|
/// the scalar FK field (<c>country_id</c>) plus junction-add operation keys
|
|
/// (<c>companies.add.<uuid></c> resolved from the suggested payload's id field).
|
|
/// Junction-remove labels are populated separately by
|
|
/// <see cref="ResolveMagazineRemoveLabelsAsync" /> directly into the current-side label
|
|
/// map so the diff panel renders them in the strikethrough cell.
|
|
/// </summary>
|
|
async Task ResolveMagazineLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
// ---- Scalar FK label ----------------------------------------------------------
|
|
if(values.TryGetValue(Suggestions.MagazineSuggestionApplier.FieldCountryId, out JsonElement countryE))
|
|
{
|
|
short? id = JsonElementToShort(countryE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Iso31661Numeric.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.MagazineSuggestionApplier.FieldCountryId] = name;
|
|
}
|
|
}
|
|
|
|
// ---- Junction-add labels (resolved from the suggested payload's id field) -----
|
|
// Note: junction-add labels look up the linked entity (Company, Role) directly from
|
|
// the suggested payload's id fields, so they DO NOT require a parent entityId. Don't
|
|
// gate this loop on entityId.HasValue — that breaks creation-mode review where the
|
|
// parent magazine doesn't exist yet but the queued company adds still need labels.
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(!Suggestions.MagazineSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out _))
|
|
continue;
|
|
if(op != "add" || kv.Value.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
string label = await BuildMagazineAddLabelAsync(group, kv.Value);
|
|
if(!string.IsNullOrEmpty(label)) labels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve readable labels for every Magazine junction-remove operation in the
|
|
/// suggested payload by looking up the existing junction row in the database. The
|
|
/// resolved labels are written to <paramref name="currentLabels" /> so the diff panel
|
|
/// renders them in the strikethrough cell of the JunctionRemove row. Iterates the RAW
|
|
/// <c>SuggestedValues</c> dict (not the JsonElement-parsed view) because remove ops
|
|
/// carry a null value and <see cref="ParseValuesForLabels" /> drops null entries.
|
|
/// </summary>
|
|
async Task ResolveMagazineRemoveLabelsAsync(long magazineId,
|
|
Dictionary<string, object> rawSuggested,
|
|
Dictionary<string, string> currentLabels)
|
|
{
|
|
foreach(KeyValuePair<string, object> kv in rawSuggested)
|
|
{
|
|
if(!Suggestions.MagazineSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out string token))
|
|
continue;
|
|
if(op != "remove") continue;
|
|
if(!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long rowId)) continue;
|
|
|
|
string label = await BuildMagazineRemoveLabelAsync(group, magazineId, rowId);
|
|
if(!string.IsNullOrEmpty(label)) currentLabels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildMagazineAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.MagazineSuggestionApplier.GroupCompanies:
|
|
{
|
|
int? id = ReadIntField(payload, "company_id");
|
|
string roleId = ReadStringField(payload, "role_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
string display = string.IsNullOrEmpty(name) ? $"Company #{id.Value}" : name;
|
|
if(!string.IsNullOrEmpty(roleId))
|
|
{
|
|
string roleName = await context.DocumentRoles.AsNoTracking()
|
|
.Where(r => r.Id == roleId)
|
|
.Select(r => r.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(roleName))
|
|
display = $"{display} ({roleName})";
|
|
}
|
|
return display;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildMagazineRemoveLabelAsync(string group, long magazineId, long rowId)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.MagazineSuggestionApplier.GroupCompanies:
|
|
{
|
|
var row = await context.CompaniesByMagazines.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MagazineId == magazineId)
|
|
.Select(r => new { r.Company.Name, RoleName = r.Role.Name })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return string.IsNullOrEmpty(row.RoleName) ? row.Name : $"{row.Name} ({row.RoleName})";
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build readable labels for a MagazineIssue suggestion's heterogeneous payload. The
|
|
/// issue itself has NO scalar FK fields exposed in the suggestion surface (the parent
|
|
/// <c>magazine_id</c> is admin-only re-parenting), so this only handles the four
|
|
/// junction-add operations. Junction-remove labels are populated separately by
|
|
/// <see cref="ResolveMagazineIssueRemoveLabelsAsync" />.
|
|
/// </summary>
|
|
async Task ResolveMagazineIssueLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
// NOTE: do NOT early-return on `!entityId.HasValue` — junction-add labels in addition
|
|
// mode (entityId == null, brand-new issue) look up the linked entity (Person, Machine,
|
|
// MachineFamily, Software) directly from the suggested payload's id field, so they
|
|
// don't need the parent issue id. Only the junction-remove label resolver needs an
|
|
// existing issue id (no rows to remove from a brand-new issue) and is gated
|
|
// separately at the call site.
|
|
_ = entityId;
|
|
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(!Suggestions.MagazineIssueSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out _))
|
|
continue;
|
|
if(op != "add" || kv.Value.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
string label = await BuildMagazineIssueAddLabelAsync(group, kv.Value);
|
|
if(!string.IsNullOrEmpty(label)) labels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve readable labels for every MagazineIssue junction-remove operation in the
|
|
/// suggested payload by looking up the existing junction row in the database. The
|
|
/// resolved labels are written to <paramref name="currentLabels" /> so the diff panel
|
|
/// renders them in the strikethrough cell of the JunctionRemove row. Iterates the RAW
|
|
/// <c>SuggestedValues</c> dict (not the JsonElement-parsed view) because remove ops
|
|
/// carry a null value and <see cref="ParseValuesForLabels" /> drops null entries.
|
|
/// </summary>
|
|
async Task ResolveMagazineIssueRemoveLabelsAsync(long issueId,
|
|
Dictionary<string, object> rawSuggested,
|
|
Dictionary<string, string> currentLabels)
|
|
{
|
|
foreach(KeyValuePair<string, object> kv in rawSuggested)
|
|
{
|
|
if(!Suggestions.MagazineIssueSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out string token))
|
|
continue;
|
|
if(op != "remove") continue;
|
|
if(!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long rowId)) continue;
|
|
|
|
string label = await BuildMagazineIssueRemoveLabelAsync(group, issueId, rowId);
|
|
if(!string.IsNullOrEmpty(label)) currentLabels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildMagazineIssueAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.MagazineIssueSuggestionApplier.GroupPeople:
|
|
{
|
|
int? id = ReadIntField(payload, "person_id");
|
|
string roleId = ReadStringField(payload, "role_id");
|
|
if(!id.HasValue) return null;
|
|
var row = await context.People.AsNoTracking()
|
|
.Where(p => p.Id == id.Value)
|
|
.Select(p => new { Display = p.DisplayName ?? p.Alias ?? (p.Name + " " + p.Surname) })
|
|
.FirstOrDefaultAsync();
|
|
string display = row is null ? $"Person #{id.Value}" : row.Display;
|
|
if(!string.IsNullOrEmpty(roleId))
|
|
{
|
|
string roleName = await context.DocumentRoles.AsNoTracking()
|
|
.Where(r => r.Id == roleId)
|
|
.Select(r => r.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(roleName))
|
|
display = $"{display} ({roleName})";
|
|
}
|
|
return display;
|
|
}
|
|
case Suggestions.MagazineIssueSuggestionApplier.GroupMachines:
|
|
{
|
|
int? id = ReadIntField(payload, "machine_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.Machines.AsNoTracking()
|
|
.Where(m => m.Id == id.Value)
|
|
.Select(m => m.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Machine #{id.Value}" : name;
|
|
}
|
|
case Suggestions.MagazineIssueSuggestionApplier.GroupMachineFamilies:
|
|
{
|
|
int? id = ReadIntField(payload, "machine_family_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.MachineFamilies.AsNoTracking()
|
|
.Where(f => f.Id == id.Value)
|
|
.Select(f => f.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Machine family #{id.Value}" : name;
|
|
}
|
|
case Suggestions.MagazineIssueSuggestionApplier.GroupSoftware:
|
|
{
|
|
long? id = ReadLongField(payload, "software_id");
|
|
if(!id.HasValue || id.Value < 0) return null;
|
|
ulong sidU = (ulong)id.Value;
|
|
string name = await context.Softwares.AsNoTracking()
|
|
.Where(s => s.Id == sidU)
|
|
.Select(s => s.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Software #{id.Value}" : name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildMagazineIssueRemoveLabelAsync(string group, long issueId, long rowId)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.MagazineIssueSuggestionApplier.GroupPeople:
|
|
{
|
|
var row = await context.PeopleByMagazines.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MagazineId == issueId)
|
|
.Select(r => new
|
|
{
|
|
Display = r.Person.DisplayName ?? r.Person.Alias ?? (r.Person.Name + " " + r.Person.Surname),
|
|
RoleName = r.Role.Name
|
|
})
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return string.IsNullOrEmpty(row.RoleName) ? row.Display : $"{row.Display} ({row.RoleName})";
|
|
}
|
|
case Suggestions.MagazineIssueSuggestionApplier.GroupMachines:
|
|
{
|
|
var row = await context.MagazinesByMachines.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MagazineId == issueId)
|
|
.Select(r => new { r.Machine.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row?.Name;
|
|
}
|
|
case Suggestions.MagazineIssueSuggestionApplier.GroupMachineFamilies:
|
|
{
|
|
var row = await context.MagazinesByMachinesFamilies.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MagazineId == issueId)
|
|
.Select(r => new { r.MachineFamily.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row?.Name;
|
|
}
|
|
case Suggestions.MagazineIssueSuggestionApplier.GroupSoftware:
|
|
{
|
|
var row = await context.MagazinesBySoftware.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.MagazineId == issueId)
|
|
.Select(r => new { r.Software.Name })
|
|
.FirstOrDefaultAsync();
|
|
return row?.Name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─────────────── Gpu entity-edit label resolution (scalars + junction ops) ───────────────
|
|
|
|
/// <summary>
|
|
/// Build readable labels for the heterogeneous payload of a Gpu suggestion. Handles
|
|
/// the scalar FK field (<c>company_id</c>) plus junction-add operation keys
|
|
/// (<c>resolutions.add.<uuid></c> resolved from the suggested payload's
|
|
/// <c>resolution_id</c>). Junction-remove labels are populated separately by
|
|
/// <see cref="ResolveGpuRemoveLabelsAsync" />.
|
|
/// </summary>
|
|
async Task ResolveGpuLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
// ---- Scalar FK label ----------------------------------------------------------
|
|
if(values.TryGetValue(Suggestions.GpuSuggestionApplier.FieldCompanyId, out JsonElement companyE))
|
|
{
|
|
int? id = JsonElementToInt(companyE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.GpuSuggestionApplier.FieldCompanyId] = name;
|
|
}
|
|
}
|
|
|
|
// NOTE: do NOT early-return on `!entityId.HasValue`. Junction-add labels look up the
|
|
// linked entity (Resolution) directly from the suggested payload's `resolution_id`
|
|
// field — they don't need the parent gpuId. The companion remove-label resolver is
|
|
// gated separately at its call site (`if(s.EntityId.HasValue) await ResolveXxxRemoveLabelsAsync(...)`).
|
|
_ = entityId;
|
|
|
|
// ---- Junction-add labels (resolved from the suggested payload's id field) -----
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(!Suggestions.GpuSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out _))
|
|
continue;
|
|
if(op != "add" || kv.Value.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
string label = await BuildGpuAddLabelAsync(group, kv.Value);
|
|
if(!string.IsNullOrEmpty(label)) labels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve readable labels for every Gpu junction-remove operation in the suggested
|
|
/// payload by looking up the existing junction row in the database. The resolved
|
|
/// labels are written to <paramref name="currentLabels" /> so the diff panel renders
|
|
/// them in the strikethrough cell of the JunctionRemove row. Iterates the RAW
|
|
/// <c>SuggestedValues</c> dict (not the JsonElement-parsed view) because remove ops
|
|
/// carry a null value and <see cref="ParseValuesForLabels" /> drops null entries.
|
|
/// </summary>
|
|
async Task ResolveGpuRemoveLabelsAsync(long gpuId,
|
|
Dictionary<string, object> rawSuggested,
|
|
Dictionary<string, string> currentLabels)
|
|
{
|
|
foreach(KeyValuePair<string, object> kv in rawSuggested)
|
|
{
|
|
if(!Suggestions.GpuSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out string token))
|
|
continue;
|
|
if(op != "remove") continue;
|
|
if(!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long rowId)) continue;
|
|
|
|
string label = await BuildGpuRemoveLabelAsync(group, (int)gpuId, rowId);
|
|
if(!string.IsNullOrEmpty(label)) currentLabels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildGpuAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.GpuSuggestionApplier.GroupResolutions:
|
|
{
|
|
int? id = ReadIntField(payload, "resolution_id");
|
|
if(!id.HasValue) return null;
|
|
var res = await context.Resolutions.AsNoTracking()
|
|
.Where(r => r.Id == id.Value)
|
|
.Select(r => new { r.Width, r.Height, r.Colors, r.Palette, r.Chars, r.Grayscale })
|
|
.FirstOrDefaultAsync();
|
|
if(res is null) return $"Resolution #{id.Value}";
|
|
return FormatResolutionLabel(res.Width, res.Height, res.Colors, res.Palette, res.Chars, res.Grayscale);
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildGpuRemoveLabelAsync(string group, int gpuId, long rowId)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.GpuSuggestionApplier.GroupResolutions:
|
|
{
|
|
var row = await context.ResolutionsByGpu.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.GpuId == gpuId)
|
|
.Select(r => new
|
|
{
|
|
r.Resolution.Width,
|
|
r.Resolution.Height,
|
|
r.Resolution.Colors,
|
|
r.Resolution.Palette,
|
|
r.Resolution.Chars,
|
|
r.Resolution.Grayscale
|
|
})
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return FormatResolutionLabel(row.Width, row.Height, row.Colors, row.Palette, row.Chars, row.Grayscale);
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side label format for a Resolution row. Mirrors the broad shape of the
|
|
/// client's <see cref="Marechai.Data.Dtos.ResolutionDto.ToString" /> without taking a
|
|
/// hard dependency on the DTO from the controller layer.
|
|
/// </summary>
|
|
static string FormatResolutionLabel(int width, int height, long? colors, long? palette, bool chars, bool grayscale)
|
|
{
|
|
string dims = $"{width}x{height}";
|
|
if(chars)
|
|
{
|
|
if(colors is null) return $"{dims} characters";
|
|
if(palette is not null && colors != palette)
|
|
return grayscale
|
|
? $"{dims} characters with {colors} grays from {palette} palette"
|
|
: $"{dims} characters with {colors} colors from {palette} palette";
|
|
return grayscale ? $"{dims} characters with {colors} grays" : $"{dims} characters with {colors} colors";
|
|
}
|
|
if(colors is null) return dims;
|
|
if(palette is not null && colors != palette)
|
|
return grayscale
|
|
? $"{dims} with {colors} grays from {palette} palette"
|
|
: $"{dims} with {colors} colors from {palette} palette";
|
|
return grayscale ? $"{dims} with {colors} grays" : $"{dims} with {colors} colors";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build readable labels for the heterogeneous payload of a Processor suggestion.
|
|
/// Handles scalar FK fields (<c>company_id</c>, <c>instruction_set_id</c>) plus
|
|
/// junction-add operation keys
|
|
/// (<c>instruction_set_extensions.add.<uuid></c> resolved from the suggested
|
|
/// payload's <c>extension_id</c>). Junction-remove labels are populated separately by
|
|
/// <see cref="ResolveProcessorRemoveLabelsAsync" />.
|
|
/// </summary>
|
|
async Task ResolveProcessorLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
// ---- Scalar FK labels ---------------------------------------------------------
|
|
if(values.TryGetValue(Suggestions.ProcessorSuggestionApplier.FieldCompanyId, out JsonElement companyE))
|
|
{
|
|
int? id = JsonElementToInt(companyE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.ProcessorSuggestionApplier.FieldCompanyId] = name;
|
|
}
|
|
}
|
|
|
|
if(values.TryGetValue(Suggestions.ProcessorSuggestionApplier.FieldInstructionSetId, out JsonElement isE))
|
|
{
|
|
int? id = JsonElementToInt(isE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.InstructionSets.AsNoTracking()
|
|
.Where(i => i.Id == id.Value)
|
|
.Select(i => i.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.ProcessorSuggestionApplier.FieldInstructionSetId] = name;
|
|
}
|
|
}
|
|
|
|
// Note: junction-add labels look up the linked InstructionSetExtension directly from
|
|
// the suggested payload's `extension_id` field — they don't need the parent
|
|
// entityId. Junction-remove labels (which DO need the parent id) are handled by the
|
|
// companion ResolveProcessorRemoveLabelsAsync, gated separately at the call site.
|
|
_ = entityId;
|
|
|
|
// ---- Junction-add labels (resolved from the suggested payload's id field) -----
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(!Suggestions.ProcessorSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out _))
|
|
continue;
|
|
if(op != "add" || kv.Value.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
string label = await BuildProcessorAddLabelAsync(group, kv.Value);
|
|
if(!string.IsNullOrEmpty(label)) labels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve readable labels for every Processor junction-remove operation in the
|
|
/// suggested payload by looking up the existing junction row in the database. The
|
|
/// resolved labels are written to <paramref name="currentLabels" /> so the diff panel
|
|
/// renders them in the strikethrough cell of the JunctionRemove row. Iterates the RAW
|
|
/// <c>SuggestedValues</c> dict (not the JsonElement-parsed view) because remove ops
|
|
/// carry a null value and <see cref="ParseValuesForLabels" /> drops null entries.
|
|
/// </summary>
|
|
async Task ResolveProcessorRemoveLabelsAsync(long processorId,
|
|
Dictionary<string, object> rawSuggested,
|
|
Dictionary<string, string> currentLabels)
|
|
{
|
|
foreach(KeyValuePair<string, object> kv in rawSuggested)
|
|
{
|
|
if(!Suggestions.ProcessorSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out string token))
|
|
continue;
|
|
if(op != "remove") continue;
|
|
if(!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long rowId)) continue;
|
|
|
|
string label = await BuildProcessorRemoveLabelAsync(group, (int)processorId, rowId);
|
|
if(!string.IsNullOrEmpty(label)) currentLabels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildProcessorAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.ProcessorSuggestionApplier.GroupInstructionSetExtensions:
|
|
{
|
|
int? id = ReadIntField(payload, "extension_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.InstructionSetExtensions.AsNoTracking()
|
|
.Where(e => e.Id == id.Value)
|
|
.Select(e => e.Extension)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Extension #{id.Value}" : name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildProcessorRemoveLabelAsync(string group, int processorId, long rowId)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.ProcessorSuggestionApplier.GroupInstructionSetExtensions:
|
|
{
|
|
string name = await context.InstructionSetExtensionsByProcessor.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.ProcessorId == processorId)
|
|
.Select(r => r.Extension.Extension)
|
|
.FirstOrDefaultAsync();
|
|
return name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build readable labels for a SoundSynth suggestion. SoundSynth has no in-scope
|
|
/// junctions (both visible junctions are owned by the other side per established
|
|
/// convention), so this only resolves the scalar <c>company_id</c> FK label.
|
|
/// </summary>
|
|
async Task ResolveSoundSynthLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
_ = entityId; // unused — no junctions.
|
|
|
|
if(values.TryGetValue(Suggestions.SoundSynthSuggestionApplier.FieldCompanyId, out JsonElement companyE))
|
|
{
|
|
int? id = JsonElementToInt(companyE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoundSynthSuggestionApplier.FieldCompanyId] = name;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build readable labels for a Person suggestion. Person has no in-scope junctions
|
|
/// (the five Person junctions are owned by the other side per the established
|
|
/// convention), so this only resolves the scalar <c>country_of_birth_id</c> FK
|
|
/// label.
|
|
/// </summary>
|
|
async Task ResolvePersonLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
_ = entityId; // unused — no junctions.
|
|
|
|
if(values.TryGetValue(Suggestions.PersonSuggestionApplier.FieldCountryOfBirthId, out JsonElement countryE))
|
|
{
|
|
short? id = countryE.ValueKind == JsonValueKind.Number && countryE.TryGetInt16(out short v)
|
|
? v
|
|
: (short?)null;
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Iso31661Numeric.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.PersonSuggestionApplier.FieldCountryOfBirthId] = name;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────── Software entity-edit label resolution (scalars + junction ops) ───────────────
|
|
|
|
/// <summary>
|
|
/// Build readable labels for a Software suggestion. Resolves three scalar FK fields
|
|
/// (<c>family_id</c>, <c>predecessor_id</c>, <c>base_software_id</c>) plus
|
|
/// junction-add operation keys (<c>genres.add.<uuid></c> resolved from the
|
|
/// suggested payload's <c>genre_id</c>). Junction-remove labels are populated
|
|
/// separately by <see cref="ResolveSoftwareRemoveLabelsAsync" />. Software is
|
|
/// <c>ulong</c>-keyed but Kiota widens FK ids to <c>int?</c> on the wire so the
|
|
/// payload values are coerced as int and re-cast to ulong for the EF queries.
|
|
/// </summary>
|
|
async Task ResolveSoftwareLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
// ---- Scalar FK labels ---------------------------------------------------------
|
|
if(values.TryGetValue(Suggestions.SoftwareSuggestionApplier.FieldFamilyId, out JsonElement familyE))
|
|
{
|
|
long? id = JsonElementToLong(familyE);
|
|
if(id.HasValue && id.Value >= 0)
|
|
{
|
|
ulong fidU = (ulong)id.Value;
|
|
string name = await context.SoftwareFamilies.AsNoTracking()
|
|
.Where(f => f.Id == fidU)
|
|
.Select(f => f.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoftwareSuggestionApplier.FieldFamilyId] = name;
|
|
}
|
|
}
|
|
|
|
if(values.TryGetValue(Suggestions.SoftwareSuggestionApplier.FieldPredecessorId, out JsonElement predecessorE))
|
|
{
|
|
long? id = JsonElementToLong(predecessorE);
|
|
if(id.HasValue && id.Value >= 0)
|
|
{
|
|
ulong pidU = (ulong)id.Value;
|
|
string name = await context.Softwares.AsNoTracking()
|
|
.Where(x => x.Id == pidU)
|
|
.Select(x => x.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoftwareSuggestionApplier.FieldPredecessorId] = name;
|
|
}
|
|
}
|
|
|
|
if(values.TryGetValue(Suggestions.SoftwareSuggestionApplier.FieldBaseSoftwareId, out JsonElement baseE))
|
|
{
|
|
long? id = JsonElementToLong(baseE);
|
|
if(id.HasValue && id.Value >= 0)
|
|
{
|
|
ulong bidU = (ulong)id.Value;
|
|
string name = await context.Softwares.AsNoTracking()
|
|
.Where(x => x.Id == bidU)
|
|
.Select(x => x.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoftwareSuggestionApplier.FieldBaseSoftwareId] = name;
|
|
}
|
|
}
|
|
|
|
// Junction-add labels look up the linked entity (genre / company+role / person)
|
|
// directly from the suggested payload's id field and don't need the parent
|
|
// softwareId. Run them in BOTH edit and creation mode (entityId is null in
|
|
// addition mode).
|
|
_ = entityId;
|
|
|
|
// ---- Junction-add labels (resolved from the suggested payload's id field) -----
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(!Suggestions.SoftwareSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out _))
|
|
continue;
|
|
if(op != "add" || kv.Value.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
string label = group switch
|
|
{
|
|
Suggestions.SoftwareSuggestionApplier.GroupGenres => await BuildSoftwareGenreAddLabelAsync(group, kv.Value),
|
|
Suggestions.SoftwareSuggestionApplier.GroupCompanies => await BuildSoftwareCompanyAddLabelAsync(group, kv.Value),
|
|
Suggestions.SoftwareSuggestionApplier.GroupCredits => await BuildSoftwareCreditAddLabelAsync(group, kv.Value),
|
|
_ => null
|
|
};
|
|
if(!string.IsNullOrEmpty(label)) labels[kv.Key] = label;
|
|
}
|
|
|
|
// ---- First-release-prefixed labels (Software creation mode only) -------------
|
|
// Forward "first_release_*" / "first_release.*" keys to the SoftwareRelease label
|
|
// resolver by stripping the prefix into a sub-dict, resolving with releaseId=null
|
|
// (creation mode), and re-prefixing the emitted labels back to the wire shape so
|
|
// the admin diff panel renders them under their original keys.
|
|
var releaseSubValues = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(kv.Key.StartsWith(Suggestions.SoftwareSuggestionApplier.FirstReleaseGroupPrefix, StringComparison.Ordinal))
|
|
releaseSubValues[kv.Key.Substring(Suggestions.SoftwareSuggestionApplier.FirstReleaseGroupPrefix.Length)] = kv.Value;
|
|
else if(kv.Key.StartsWith(Suggestions.SoftwareSuggestionApplier.FirstReleaseScalarPrefix, StringComparison.Ordinal))
|
|
releaseSubValues[kv.Key.Substring(Suggestions.SoftwareSuggestionApplier.FirstReleaseScalarPrefix.Length)] = kv.Value;
|
|
}
|
|
if(releaseSubValues.Count > 0)
|
|
{
|
|
var releaseLabels = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
await ResolveSoftwareReleaseLabelsAsync(null, releaseSubValues, releaseLabels);
|
|
foreach(KeyValuePair<string, string> kv in releaseLabels)
|
|
{
|
|
// Junction-op keys contain a dot ("group.op.token"); scalars never do.
|
|
string prefix = kv.Key.Contains('.', StringComparison.Ordinal)
|
|
? Suggestions.SoftwareSuggestionApplier.FirstReleaseGroupPrefix
|
|
: Suggestions.SoftwareSuggestionApplier.FirstReleaseScalarPrefix;
|
|
labels[prefix + kv.Key] = kv.Value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve readable labels for a SoftwareVersion creation suggestion. Resolves
|
|
/// <c>software_id</c> to <c>Software.Name</c>, <c>parent_version_id</c> to its
|
|
/// version string, and <c>license_id</c> to its name. First-release-prefixed keys
|
|
/// are forwarded to <see cref="ResolveSoftwareReleaseLabelsAsync" /> via pre-strip
|
|
/// + delegate (matches <see cref="ResolveSoftwareLabelsAsync" />). The applier has
|
|
/// no junctions on the version side; companies stay admin-only.
|
|
/// </summary>
|
|
async Task ResolveSoftwareVersionLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
_ = entityId;
|
|
|
|
// ---- software_id → Software.Name ---------------------------------------------
|
|
if(values.TryGetValue(Suggestions.SoftwareVersionSuggestionApplier.FieldSoftwareId, out JsonElement softwareE))
|
|
{
|
|
long? id = JsonElementToLong(softwareE);
|
|
if(id.HasValue && id.Value >= 0)
|
|
{
|
|
ulong sidU = (ulong)id.Value;
|
|
string name = await context.Softwares.AsNoTracking()
|
|
.Where(x => x.Id == sidU)
|
|
.Select(x => x.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoftwareVersionSuggestionApplier.FieldSoftwareId] = name;
|
|
}
|
|
}
|
|
|
|
// ---- parent_version_id → SoftwareVersion.VersionString (joined with software) ----
|
|
if(values.TryGetValue(Suggestions.SoftwareVersionSuggestionApplier.FieldParentVersionId, out JsonElement pvE))
|
|
{
|
|
long? id = JsonElementToLong(pvE);
|
|
if(id.HasValue && id.Value >= 0)
|
|
{
|
|
ulong pvU = (ulong)id.Value;
|
|
var row = await context.SoftwareVersions.AsNoTracking()
|
|
.Where(v => v.Id == pvU)
|
|
.Select(v => new { v.VersionString, ParentName = v.Software.Name })
|
|
.FirstOrDefaultAsync();
|
|
if(row is not null)
|
|
{
|
|
string vlabel = string.IsNullOrEmpty(row.ParentName)
|
|
? row.VersionString
|
|
: $"{row.ParentName} {row.VersionString}";
|
|
if(!string.IsNullOrEmpty(vlabel))
|
|
labels[Suggestions.SoftwareVersionSuggestionApplier.FieldParentVersionId] = vlabel;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- license_id → License.Name -----------------------------------------------
|
|
if(values.TryGetValue(Suggestions.SoftwareVersionSuggestionApplier.FieldLicenseId, out JsonElement licE))
|
|
{
|
|
int? id = JsonElementToInt(licE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Licenses.AsNoTracking()
|
|
.Where(l => l.Id == id.Value)
|
|
.Select(l => l.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoftwareVersionSuggestionApplier.FieldLicenseId] = name;
|
|
}
|
|
}
|
|
|
|
// ---- First-release-prefixed labels (creation mode only) ----------------------
|
|
// Pre-strip + delegate to the release-side resolver, then re-prefix emitted
|
|
// labels back to the wire shape. Junction-op keys contain a dot, scalars don't.
|
|
var releaseSubValues = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(kv.Key.StartsWith(Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseGroupPrefix, StringComparison.Ordinal))
|
|
releaseSubValues[kv.Key.Substring(Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseGroupPrefix.Length)] = kv.Value;
|
|
else if(kv.Key.StartsWith(Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix, StringComparison.Ordinal))
|
|
releaseSubValues[kv.Key.Substring(Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix.Length)] = kv.Value;
|
|
}
|
|
if(releaseSubValues.Count > 0)
|
|
{
|
|
var releaseLabels = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
await ResolveSoftwareReleaseLabelsAsync(null, releaseSubValues, releaseLabels);
|
|
foreach(KeyValuePair<string, string> kv in releaseLabels)
|
|
{
|
|
string prefix = kv.Key.Contains('.', StringComparison.Ordinal)
|
|
? Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseGroupPrefix
|
|
: Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix;
|
|
labels[prefix + kv.Key] = kv.Value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve readable labels for every Software junction-remove operation in the
|
|
/// suggested payload by looking up the targeted genre row. The resolved labels are
|
|
/// written to <paramref name="currentLabels" /> so the diff panel renders them in the
|
|
/// strikethrough cell of the JunctionRemove row. Iterates the RAW
|
|
/// <c>SuggestedValues</c> dict (not the JsonElement-parsed view) because remove ops
|
|
/// carry a null value and <see cref="ParseValuesForLabels" /> drops null entries.
|
|
/// Note: the genres remove token is the genre id itself (composite-key junction has
|
|
/// no surrogate row id), so the lookup verifies the link exists for THIS software
|
|
/// before resolving the genre name.
|
|
/// </summary>
|
|
async Task ResolveSoftwareRemoveLabelsAsync(long softwareId,
|
|
Dictionary<string, object> rawSuggested,
|
|
Dictionary<string, string> currentLabels)
|
|
{
|
|
foreach(KeyValuePair<string, object> kv in rawSuggested)
|
|
{
|
|
if(!Suggestions.SoftwareSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out string token))
|
|
continue;
|
|
if(op != "remove") continue;
|
|
|
|
string label = null;
|
|
switch(group)
|
|
{
|
|
case Suggestions.SoftwareSuggestionApplier.GroupGenres:
|
|
{
|
|
if(!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out int genreId)) continue;
|
|
label = await BuildSoftwareGenreRemoveLabelAsync(group, (ulong)softwareId, genreId);
|
|
break;
|
|
}
|
|
case Suggestions.SoftwareSuggestionApplier.GroupCompanies:
|
|
{
|
|
// 2-component composite token "{companyId}_{roleId}".
|
|
string[] parts = token.Split('_', 2);
|
|
if(parts.Length != 2 || string.IsNullOrEmpty(parts[1])) continue;
|
|
if(!int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int companyId)) continue;
|
|
label = await BuildSoftwareCompanyRemoveLabelAsync(group, (ulong)softwareId, companyId, parts[1]);
|
|
break;
|
|
}
|
|
case Suggestions.SoftwareSuggestionApplier.GroupCredits:
|
|
{
|
|
// PeopleBySoftware has a surrogate row Id, so the credits remove token
|
|
// is the row id directly (parsed as long). The lookup is gated on
|
|
// SoftwareId to keep the displayed label scoped to this entity.
|
|
if(!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long rowId)) continue;
|
|
label = await BuildSoftwareCreditRemoveLabelAsync(group, (ulong)softwareId, rowId);
|
|
break;
|
|
}
|
|
}
|
|
if(!string.IsNullOrEmpty(label)) currentLabels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildSoftwareGenreAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.SoftwareSuggestionApplier.GroupGenres:
|
|
{
|
|
int? id = ReadIntField(payload, "genre_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.SoftwareGenres.AsNoTracking()
|
|
.Where(g => g.Id == id.Value)
|
|
.Select(g => g.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Genre #{id.Value}" : name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildSoftwareGenreRemoveLabelAsync(string group, ulong softwareId, int genreId)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.SoftwareSuggestionApplier.GroupGenres:
|
|
{
|
|
// Verify the link exists for THIS software; only then resolve the name from
|
|
// the genres table. Returning null for a stale remove op leaves the diff
|
|
// panel cell blank rather than showing a misleading label.
|
|
string name = await context.GenresBySoftware.AsNoTracking()
|
|
.Where(r => r.SoftwareId == softwareId && r.GenreId == genreId)
|
|
.Select(r => r.Genre.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? null : name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildSoftwareCompanyAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.SoftwareSuggestionApplier.GroupCompanies:
|
|
{
|
|
int? id = ReadIntField(payload, "company_id");
|
|
string roleId = ReadStringField(payload, "role_id");
|
|
if(!id.HasValue) return null;
|
|
string name = await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
string display = string.IsNullOrEmpty(name) ? $"Company #{id.Value}" : name;
|
|
if(!string.IsNullOrEmpty(roleId))
|
|
{
|
|
string roleName = await context.SoftwareRoles.AsNoTracking()
|
|
.Where(r => r.Id == roleId)
|
|
.Select(r => r.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(roleName))
|
|
display = $"{display} ({roleName})";
|
|
}
|
|
return display;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildSoftwareCompanyRemoveLabelAsync(string group, ulong softwareId, int companyId, string roleId)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.SoftwareSuggestionApplier.GroupCompanies:
|
|
{
|
|
// Verify the link exists for THIS software (composite-key guard); only then
|
|
// resolve readable names. Stale remove ops yield null → diff panel cell
|
|
// stays blank rather than showing a misleading label.
|
|
var row = await context.SoftwareCompanyRoles.AsNoTracking()
|
|
.Where(r => r.SoftwareId == softwareId &&
|
|
r.CompanyId == companyId &&
|
|
r.RoleId == roleId)
|
|
.Select(r => new { CompanyName = r.Company.Name, RoleName = r.Role.Name })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return string.IsNullOrEmpty(row.RoleName) ? row.CompanyName : $"{row.CompanyName} ({row.RoleName})";
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildSoftwareCreditAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.SoftwareSuggestionApplier.GroupCredits:
|
|
{
|
|
int? id = ReadIntField(payload, "person_id");
|
|
string role = ReadStringField(payload, "role");
|
|
if(!id.HasValue) return null;
|
|
var p = await context.People.AsNoTracking()
|
|
.Where(x => x.Id == id.Value)
|
|
.Select(x => new
|
|
{
|
|
x.Name,
|
|
x.Surname,
|
|
x.Alias,
|
|
x.DisplayName
|
|
})
|
|
.FirstOrDefaultAsync();
|
|
string display = p is null
|
|
? $"Person #{id.Value}"
|
|
: (!string.IsNullOrEmpty(p.DisplayName) ? p.DisplayName
|
|
: !string.IsNullOrEmpty(p.Alias) ? p.Alias
|
|
: $"{p.Name} {p.Surname}".Trim());
|
|
if(string.IsNullOrWhiteSpace(display)) display = $"Person #{id.Value}";
|
|
return string.IsNullOrEmpty(role) ? display : $"{display} ({role})";
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildSoftwareCreditRemoveLabelAsync(string group, ulong softwareId, long rowId)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.SoftwareSuggestionApplier.GroupCredits:
|
|
{
|
|
// Verify the link exists for THIS software (surrogate-Id guard); only then
|
|
// resolve readable names. Stale remove ops yield null → diff panel cell
|
|
// stays blank rather than showing a misleading label.
|
|
var row = await context.PeopleBySoftware.AsNoTracking()
|
|
.Where(r => r.Id == rowId && r.SoftwareId == softwareId)
|
|
.Select(r => new
|
|
{
|
|
r.Person.Name,
|
|
r.Person.Surname,
|
|
r.Person.Alias,
|
|
r.Person.DisplayName,
|
|
r.Role
|
|
})
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
string name = !string.IsNullOrEmpty(row.DisplayName) ? row.DisplayName
|
|
: !string.IsNullOrEmpty(row.Alias) ? row.Alias
|
|
: $"{row.Name} {row.Surname}".Trim();
|
|
if(string.IsNullOrWhiteSpace(name)) name = $"Person #{rowId}";
|
|
return string.IsNullOrEmpty(row.Role) ? name : $"{name} ({row.Role})";
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─────────────── SoftwareRelease entity-edit label resolution (scalars + junction ops) ───────────────
|
|
|
|
/// <summary>
|
|
/// Build readable labels for a SoftwareRelease suggestion. Resolves two scalar FK
|
|
/// fields (<c>platform_id</c>, <c>publisher_id</c>) plus junction-add operation keys
|
|
/// for all four in-scope groups. Junction-remove labels are populated separately by
|
|
/// <see cref="ResolveSoftwareReleaseRemoveLabelsAsync" />.
|
|
/// </summary>
|
|
async Task ResolveSoftwareReleaseLabelsAsync(long? entityId,
|
|
Dictionary<string, JsonElement> values,
|
|
Dictionary<string, string> labels)
|
|
{
|
|
// ---- Scalar FK labels ---------------------------------------------------------
|
|
if(values.TryGetValue(Suggestions.SoftwareReleaseSuggestionApplier.FieldPlatformId, out JsonElement platE))
|
|
{
|
|
long? id = JsonElementToLong(platE);
|
|
if(id.HasValue && id.Value >= 0)
|
|
{
|
|
ulong pidU = (ulong)id.Value;
|
|
string name = await context.SoftwarePlatforms.AsNoTracking()
|
|
.Where(p => p.Id == pidU)
|
|
.Select(p => p.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoftwareReleaseSuggestionApplier.FieldPlatformId] = name;
|
|
}
|
|
}
|
|
|
|
if(values.TryGetValue(Suggestions.SoftwareReleaseSuggestionApplier.FieldPublisherId, out JsonElement pubE))
|
|
{
|
|
int? id = JsonElementToInt(pubE);
|
|
if(id.HasValue)
|
|
{
|
|
string name = await context.Companies.AsNoTracking()
|
|
.Where(c => c.Id == id.Value)
|
|
.Select(c => c.Name)
|
|
.FirstOrDefaultAsync();
|
|
if(!string.IsNullOrEmpty(name))
|
|
labels[Suggestions.SoftwareReleaseSuggestionApplier.FieldPublisherId] = name;
|
|
}
|
|
}
|
|
|
|
// Junction-add labels look up the linked entity (gpu / sound synth / language /
|
|
// region / barcode payload / etc.) directly from the suggested payload's id /
|
|
// payload fields and don't need the parent releaseId. Run them in BOTH edit and
|
|
// creation mode (entityId is null in addition mode).
|
|
_ = entityId;
|
|
|
|
// ---- Junction-add labels (resolved from the suggested payload's id field) -----
|
|
foreach(KeyValuePair<string, JsonElement> kv in values)
|
|
{
|
|
if(!Suggestions.SoftwareReleaseSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out _))
|
|
continue;
|
|
if(op != "add" || kv.Value.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
string label = await BuildSoftwareReleaseAddLabelAsync(group, kv.Value);
|
|
if(!string.IsNullOrEmpty(label)) labels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve readable labels for every SoftwareRelease junction-remove operation in the
|
|
/// suggested payload. Iterates the RAW <c>SuggestedValues</c> dict because remove ops
|
|
/// carry a null value and <see cref="ParseValuesForLabels" /> drops null entries.
|
|
/// </summary>
|
|
async Task ResolveSoftwareReleaseRemoveLabelsAsync(long releaseId,
|
|
Dictionary<string, object> rawSuggested,
|
|
Dictionary<string, string> currentLabels)
|
|
{
|
|
ulong releaseIdU = (ulong)releaseId;
|
|
|
|
foreach(KeyValuePair<string, object> kv in rawSuggested)
|
|
{
|
|
if(!Suggestions.SoftwareReleaseSuggestionApplier.TryParseJunctionKey(kv.Key, out string group, out string op, out string token))
|
|
continue;
|
|
if(op != "remove") continue;
|
|
|
|
string label = group switch
|
|
{
|
|
Suggestions.SoftwareReleaseSuggestionApplier.GroupRegions => await BuildSoftwareReleaseRegionRemoveLabelAsync(releaseIdU, token),
|
|
Suggestions.SoftwareReleaseSuggestionApplier.GroupLanguages => await BuildSoftwareReleaseLanguageRemoveLabelAsync(releaseIdU, token),
|
|
Suggestions.SoftwareReleaseSuggestionApplier.GroupBarcodes => await BuildSoftwareReleaseBarcodeRemoveLabelAsync(releaseIdU, token),
|
|
Suggestions.SoftwareReleaseSuggestionApplier.GroupProductCodes => await BuildSoftwareReleaseProductCodeRemoveLabelAsync(releaseIdU, token),
|
|
Suggestions.SoftwareReleaseSuggestionApplier.GroupSpecs => await BuildSoftwareReleaseAttributeRemoveLabelAsync(releaseIdU, token, Suggestions.SoftwareReleaseSuggestionApplier.AttributeCategorySpec),
|
|
Suggestions.SoftwareReleaseSuggestionApplier.GroupRatings => await BuildSoftwareReleaseAttributeRemoveLabelAsync(releaseIdU, token, Suggestions.SoftwareReleaseSuggestionApplier.AttributeCategoryRating),
|
|
Suggestions.SoftwareReleaseSuggestionApplier.GroupMinGpus => await BuildSoftwareReleaseMinGpuRemoveLabelAsync(releaseIdU, token),
|
|
Suggestions.SoftwareReleaseSuggestionApplier.GroupRecGpus => await BuildSoftwareReleaseRecGpuRemoveLabelAsync(releaseIdU, token),
|
|
Suggestions.SoftwareReleaseSuggestionApplier.GroupSoundSynths => await BuildSoftwareReleaseSoundSynthRemoveLabelAsync(releaseIdU, token),
|
|
_ => null
|
|
};
|
|
if(!string.IsNullOrEmpty(label)) currentLabels[kv.Key] = label;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildSoftwareReleaseAddLabelAsync(string group, JsonElement payload)
|
|
{
|
|
switch(group)
|
|
{
|
|
case Suggestions.SoftwareReleaseSuggestionApplier.GroupRegions:
|
|
{
|
|
int? id = ReadIntField(payload, "unm49_id");
|
|
if(!id.HasValue || id.Value < short.MinValue || id.Value > short.MaxValue) return null;
|
|
short sid = (short)id.Value;
|
|
string name = await context.UnM49.AsNoTracking()
|
|
.Where(u => u.Id == sid)
|
|
.Select(u => u.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Region #{sid}" : name;
|
|
}
|
|
case Suggestions.SoftwareReleaseSuggestionApplier.GroupLanguages:
|
|
{
|
|
string code = ReadStringField(payload, "language_code");
|
|
if(string.IsNullOrWhiteSpace(code)) return null;
|
|
code = code.Trim();
|
|
string name = await context.Iso639.AsNoTracking()
|
|
.Where(l => l.Id == code)
|
|
.Select(l => l.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? code : name;
|
|
}
|
|
case Suggestions.SoftwareReleaseSuggestionApplier.GroupBarcodes:
|
|
{
|
|
string code = ReadStringField(payload, "code");
|
|
int? type = ReadIntField(payload, "type");
|
|
if(string.IsNullOrEmpty(code) || !type.HasValue) return null;
|
|
string typeLabel = Enum.IsDefined(typeof(BarcodeType), (byte)type.Value)
|
|
? ((BarcodeType)type.Value).ToString()
|
|
: $"#{type.Value}";
|
|
return $"{typeLabel}: {code}";
|
|
}
|
|
case Suggestions.SoftwareReleaseSuggestionApplier.GroupProductCodes:
|
|
{
|
|
string code = ReadStringField(payload, "code");
|
|
int? issuer = ReadIntField(payload, "issuer");
|
|
if(string.IsNullOrEmpty(code) || !issuer.HasValue) return null;
|
|
string issuerLabel = Enum.IsDefined(typeof(ProductCodeIssuer), (byte)issuer.Value)
|
|
? ((ProductCodeIssuer)issuer.Value).ToString()
|
|
: $"#{issuer.Value}";
|
|
return $"{issuerLabel}: {code}";
|
|
}
|
|
case Suggestions.SoftwareReleaseSuggestionApplier.GroupSpecs:
|
|
case Suggestions.SoftwareReleaseSuggestionApplier.GroupRatings:
|
|
{
|
|
string key = ReadStringField(payload, "key");
|
|
string value = ReadStringField(payload, "value");
|
|
if(string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) return null;
|
|
return $"{key.Trim()}: {value.Trim()}";
|
|
}
|
|
case Suggestions.SoftwareReleaseSuggestionApplier.GroupMinGpus:
|
|
case Suggestions.SoftwareReleaseSuggestionApplier.GroupRecGpus:
|
|
{
|
|
int? gpuId = ReadIntField(payload, "gpu_id");
|
|
if(!gpuId.HasValue) return null;
|
|
string name = await context.Gpus.AsNoTracking()
|
|
.Where(g => g.Id == gpuId.Value)
|
|
.Select(g => g.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"GPU #{gpuId.Value}" : name;
|
|
}
|
|
case Suggestions.SoftwareReleaseSuggestionApplier.GroupSoundSynths:
|
|
{
|
|
int? synthId = ReadIntField(payload, "sound_synth_id");
|
|
if(!synthId.HasValue) return null;
|
|
string name = await context.SoundSynths.AsNoTracking()
|
|
.Where(s => s.Id == synthId.Value)
|
|
.Select(s => s.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? $"Sound synth #{synthId.Value}" : name;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async Task<string> BuildSoftwareReleaseRegionRemoveLabelAsync(ulong releaseId, string token)
|
|
{
|
|
if(!short.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out short id)) return null;
|
|
// Verify the link exists for THIS release (composite-key guard).
|
|
string name = await context.UnM49BySoftwareRelease.AsNoTracking()
|
|
.Where(x => x.SoftwareReleaseId == releaseId && x.UnM49Id == id)
|
|
.Select(x => x.UnM49.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? null : name;
|
|
}
|
|
|
|
async Task<string> BuildSoftwareReleaseLanguageRemoveLabelAsync(ulong releaseId, string token)
|
|
{
|
|
// String-keyed composite junction \u2014 token IS the language code.
|
|
string code = token;
|
|
if(string.IsNullOrEmpty(code)) return null;
|
|
string name = await context.LanguageBySoftwareRelease.AsNoTracking()
|
|
.Where(x => x.SoftwareReleaseId == releaseId && x.LanguageCode == code)
|
|
.Select(x => x.Language.ReferenceName)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? null : name;
|
|
}
|
|
|
|
async Task<string> BuildSoftwareReleaseBarcodeRemoveLabelAsync(ulong releaseId, string token)
|
|
{
|
|
if(!ulong.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong rowId)) return null;
|
|
var row = await context.SoftwareBarcodes.AsNoTracking()
|
|
.Where(b => b.Id == rowId && b.ReleaseId == releaseId)
|
|
.Select(b => new { b.Code, b.Type })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return $"{row.Type}: {row.Code}";
|
|
}
|
|
|
|
async Task<string> BuildSoftwareReleaseProductCodeRemoveLabelAsync(ulong releaseId, string token)
|
|
{
|
|
if(!ulong.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong rowId)) return null;
|
|
var row = await context.SoftwareProductCodes.AsNoTracking()
|
|
.Where(p => p.Id == rowId && p.ReleaseId == releaseId)
|
|
.Select(p => new { p.Code, p.Issuer })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return $"{row.Issuer}: {row.Code}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve the readable label for a SoftwareAttribute (spec or rating) remove
|
|
/// operation. Gating on both <c>SoftwareReleaseId</c> and <c>Category</c> mirrors
|
|
/// the applier guard so a Specs dialog cannot resolve a Rating row id (the lookup
|
|
/// simply returns null in that case).
|
|
/// </summary>
|
|
async Task<string> BuildSoftwareReleaseAttributeRemoveLabelAsync(ulong releaseId, string token, string category)
|
|
{
|
|
if(!long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long rowId)) return null;
|
|
var row = await context.SoftwareAttributes.AsNoTracking()
|
|
.Where(a => a.Id == rowId &&
|
|
a.SoftwareReleaseId == releaseId &&
|
|
a.Category == category)
|
|
.Select(a => new { a.Key, a.Value })
|
|
.FirstOrDefaultAsync();
|
|
if(row is null) return null;
|
|
return $"{row.Key}: {row.Value}";
|
|
}
|
|
|
|
async Task<string> BuildSoftwareReleaseMinGpuRemoveLabelAsync(ulong releaseId, string token)
|
|
{
|
|
if(!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out int gpuId)) return null;
|
|
string name = await context.MinimumGpuBySoftwareRelease.AsNoTracking()
|
|
.Where(x => x.ReleaseId == releaseId && x.GpuId == gpuId)
|
|
.Select(x => x.Gpu.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? null : name;
|
|
}
|
|
|
|
async Task<string> BuildSoftwareReleaseRecGpuRemoveLabelAsync(ulong releaseId, string token)
|
|
{
|
|
if(!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out int gpuId)) return null;
|
|
string name = await context.RecommendedGpuBySoftwareRelease.AsNoTracking()
|
|
.Where(x => x.ReleaseId == releaseId && x.GpuId == gpuId)
|
|
.Select(x => x.Gpu.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? null : name;
|
|
}
|
|
|
|
async Task<string> BuildSoftwareReleaseSoundSynthRemoveLabelAsync(ulong releaseId, string token)
|
|
{
|
|
if(!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out int synthId)) return null;
|
|
string name = await context.SoundSynthBySoftwareRelease.AsNoTracking()
|
|
.Where(x => x.ReleaseId == releaseId && x.SoundSynthId == synthId)
|
|
.Select(x => x.SoundSynth.Name)
|
|
.FirstOrDefaultAsync();
|
|
return string.IsNullOrEmpty(name) ? null : name;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sweep pending image uploads referenced by a now-terminal suggestion. Called from
|
|
/// <see cref="ReviewAsync" /> after the per-entity applier has run. For every
|
|
/// <c>cover_pending_guid</c> in the suggested payload that did NOT make it into the
|
|
/// <paramref name="accepted" /> set, delete the orphaned pending file + sidecar.
|
|
/// The accepted path is already cleaned up by the applier (the file is moved into
|
|
/// <c>originals/</c> and the sidecar removed) so we only need to handle rejection /
|
|
/// partial-accept-without-cover here. Best-effort: failures are swallowed.
|
|
/// </summary>
|
|
void CleanupPendingMedia(Suggestion s, HashSet<string> accepted)
|
|
{
|
|
if(s?.SuggestedValues is null || string.IsNullOrEmpty(_assetRootPath)) return;
|
|
|
|
try
|
|
{
|
|
switch(s.EntityType)
|
|
{
|
|
case SuggestionEntityType.Book:
|
|
{
|
|
if(!s.SuggestedValues.TryGetValue(
|
|
Suggestions.BookSuggestionApplier.FieldCoverPendingGuid, out object guidRaw))
|
|
return;
|
|
|
|
string guidStr = guidRaw switch
|
|
{
|
|
string str => str,
|
|
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
|
_ => guidRaw?.ToString()
|
|
};
|
|
if(string.IsNullOrEmpty(guidStr) || !Guid.TryParse(guidStr, out Guid pendingGuid)) return;
|
|
|
|
// If the cover field was accepted, the applier already promoted the file
|
|
// (and removed the sidecar). Only sweep when it wasn't accepted.
|
|
if(accepted.Contains(Suggestions.BookSuggestionApplier.FieldCoverPendingGuid)) return;
|
|
|
|
Marechai.Server.Helpers.PendingImageStore.Delete(_assetRootPath, "book-covers", pendingGuid);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.MagazineIssue:
|
|
{
|
|
if(!s.SuggestedValues.TryGetValue(
|
|
Suggestions.MagazineIssueSuggestionApplier.FieldCoverPendingGuid, out object guidRaw))
|
|
return;
|
|
|
|
string guidStr = guidRaw switch
|
|
{
|
|
string str => str,
|
|
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
|
_ => guidRaw?.ToString()
|
|
};
|
|
if(string.IsNullOrEmpty(guidStr) || !Guid.TryParse(guidStr, out Guid pendingGuid)) return;
|
|
|
|
// If the cover field was accepted, the applier already promoted the file
|
|
// (and removed the sidecar). Only sweep when it wasn't accepted.
|
|
if(accepted.Contains(Suggestions.MagazineIssueSuggestionApplier.FieldCoverPendingGuid)) return;
|
|
|
|
Marechai.Server.Helpers.PendingImageStore.Delete(_assetRootPath, "magazine-issue-covers", pendingGuid);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.Person:
|
|
{
|
|
if(!s.SuggestedValues.TryGetValue(
|
|
Suggestions.PersonSuggestionApplier.FieldCoverPendingGuid, out object guidRaw))
|
|
return;
|
|
|
|
string guidStr = guidRaw switch
|
|
{
|
|
string str => str,
|
|
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
|
_ => guidRaw?.ToString()
|
|
};
|
|
if(string.IsNullOrEmpty(guidStr) || !Guid.TryParse(guidStr, out Guid pendingGuid)) return;
|
|
|
|
// If the photo field was accepted, the applier already promoted the file
|
|
// (and removed the sidecar). Only sweep when it wasn't accepted.
|
|
if(accepted.Contains(Suggestions.PersonSuggestionApplier.FieldCoverPendingGuid)) return;
|
|
|
|
Marechai.Server.Helpers.PendingImageStore.Delete(_assetRootPath, "people", pendingGuid);
|
|
break;
|
|
}
|
|
case SuggestionEntityType.GpuPhoto:
|
|
{
|
|
// Sweep every per-photo pending file the suggester referenced that did NOT
|
|
// end up accepted. The applier already deletes rejected pending files when
|
|
// the admin submits the review (see GpuPhotoSuggestionApplier.ApplyAsync's
|
|
// "rejected → Delete" branch), so this catch-all only fires for the
|
|
// never-reviewed paths (withdraw, stale, controller exception fallback).
|
|
if(!s.SuggestedValues.TryGetValue(
|
|
Suggestions.GpuPhotoSuggestionApplier.FieldPhotos, out object photosRaw))
|
|
return;
|
|
|
|
if(photosRaw is not JsonElement arr || arr.ValueKind != JsonValueKind.Array) return;
|
|
|
|
foreach(JsonElement photo in arr.EnumerateArray())
|
|
{
|
|
if(photo.ValueKind != JsonValueKind.Object) continue;
|
|
if(!photo.TryGetProperty("guid", out JsonElement gj) ||
|
|
gj.ValueKind != JsonValueKind.String) continue;
|
|
if(!Guid.TryParse(gj.GetString(), out Guid pg)) continue;
|
|
|
|
string acceptKey = Suggestions.GpuPhotoSuggestionApplier.PhotoAcceptKey(pg);
|
|
|
|
// Skip files that were just promoted (the applier deleted the sidecar
|
|
// already; calling Delete here is harmless thanks to the silent
|
|
// fallback but we save the IO).
|
|
if(accepted.Contains(acceptKey)) continue;
|
|
|
|
Marechai.Server.Helpers.PendingImageStore.Delete(_assetRootPath, "gpus", pg);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case SuggestionEntityType.ProcessorPhoto:
|
|
{
|
|
// Sweep every per-photo pending file the suggester referenced that did NOT
|
|
// end up accepted. The applier already deletes rejected pending files when
|
|
// the admin submits the review (see ProcessorPhotoSuggestionApplier.ApplyAsync's
|
|
// "rejected → Delete" branch), so this catch-all only fires for the
|
|
// never-reviewed paths (withdraw, stale, controller exception fallback).
|
|
if(!s.SuggestedValues.TryGetValue(
|
|
Suggestions.ProcessorPhotoSuggestionApplier.FieldPhotos, out object photosRaw))
|
|
return;
|
|
|
|
if(photosRaw is not JsonElement arr || arr.ValueKind != JsonValueKind.Array) return;
|
|
|
|
foreach(JsonElement photo in arr.EnumerateArray())
|
|
{
|
|
if(photo.ValueKind != JsonValueKind.Object) continue;
|
|
if(!photo.TryGetProperty("guid", out JsonElement gj) ||
|
|
gj.ValueKind != JsonValueKind.String) continue;
|
|
if(!Guid.TryParse(gj.GetString(), out Guid pg)) continue;
|
|
|
|
string acceptKey = Suggestions.ProcessorPhotoSuggestionApplier.PhotoAcceptKey(pg);
|
|
|
|
// Skip files that were just promoted (the applier deleted the sidecar
|
|
// already; calling Delete here is harmless thanks to the silent
|
|
// fallback but we save the IO).
|
|
if(accepted.Contains(acceptKey)) continue;
|
|
|
|
Marechai.Server.Helpers.PendingImageStore.Delete(_assetRootPath, "processors", pg);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case SuggestionEntityType.SoundSynthPhoto:
|
|
{
|
|
// Sweep every per-photo pending file the suggester referenced that did NOT
|
|
// end up accepted. The applier already deletes rejected pending files when
|
|
// the admin submits the review (see SoundSynthPhotoSuggestionApplier.ApplyAsync's
|
|
// "rejected → Delete" branch), so this catch-all only fires for the
|
|
// never-reviewed paths (withdraw, stale, controller exception fallback).
|
|
if(!s.SuggestedValues.TryGetValue(
|
|
Suggestions.SoundSynthPhotoSuggestionApplier.FieldPhotos, out object photosRaw))
|
|
return;
|
|
|
|
if(photosRaw is not JsonElement arr || arr.ValueKind != JsonValueKind.Array) return;
|
|
|
|
foreach(JsonElement photo in arr.EnumerateArray())
|
|
{
|
|
if(photo.ValueKind != JsonValueKind.Object) continue;
|
|
if(!photo.TryGetProperty("guid", out JsonElement gj) ||
|
|
gj.ValueKind != JsonValueKind.String) continue;
|
|
if(!Guid.TryParse(gj.GetString(), out Guid pg)) continue;
|
|
|
|
string acceptKey = Suggestions.SoundSynthPhotoSuggestionApplier.PhotoAcceptKey(pg);
|
|
|
|
// Skip files that were just promoted (the applier deleted the sidecar
|
|
// already; calling Delete here is harmless thanks to the silent
|
|
// fallback but we save the IO).
|
|
if(accepted.Contains(acceptKey)) continue;
|
|
|
|
Marechai.Server.Helpers.PendingImageStore.Delete(_assetRootPath, "sound-synths", pg);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case SuggestionEntityType.MachinePhoto:
|
|
{
|
|
// Sweep every per-photo pending file the suggester referenced that did NOT
|
|
// end up accepted. Mirrors the SoundSynthPhoto branch above.
|
|
if(!s.SuggestedValues.TryGetValue(
|
|
Suggestions.MachinePhotoSuggestionApplier.FieldPhotos, out object photosRaw))
|
|
return;
|
|
|
|
if(photosRaw is not JsonElement arr || arr.ValueKind != JsonValueKind.Array) return;
|
|
|
|
foreach(JsonElement photo in arr.EnumerateArray())
|
|
{
|
|
if(photo.ValueKind != JsonValueKind.Object) continue;
|
|
if(!photo.TryGetProperty("guid", out JsonElement gj) ||
|
|
gj.ValueKind != JsonValueKind.String) continue;
|
|
if(!Guid.TryParse(gj.GetString(), out Guid pg)) continue;
|
|
|
|
string acceptKey = Suggestions.MachinePhotoSuggestionApplier.PhotoAcceptKey(pg);
|
|
|
|
if(accepted.Contains(acceptKey)) continue;
|
|
|
|
Marechai.Server.Helpers.PendingImageStore.Delete(_assetRootPath, "machines", pg);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case SuggestionEntityType.SoftwarePromoArt:
|
|
{
|
|
// Sweep every per-image pending file the suggester referenced that did NOT
|
|
// end up accepted. The applier already deletes rejected pending files when
|
|
// the admin submits the review; this catch-all only fires for the
|
|
// never-reviewed paths (withdraw, stale, controller exception fallback).
|
|
if(!s.SuggestedValues.TryGetValue(
|
|
Suggestions.SoftwarePromoArtSuggestionApplier.FieldPhotos, out object photosRaw))
|
|
return;
|
|
|
|
if(photosRaw is not JsonElement arr || arr.ValueKind != JsonValueKind.Array) return;
|
|
|
|
foreach(JsonElement photo in arr.EnumerateArray())
|
|
{
|
|
if(photo.ValueKind != JsonValueKind.Object) continue;
|
|
if(!photo.TryGetProperty("guid", out JsonElement gj) ||
|
|
gj.ValueKind != JsonValueKind.String) continue;
|
|
if(!Guid.TryParse(gj.GetString(), out Guid pg)) continue;
|
|
|
|
string acceptKey = Suggestions.SoftwarePromoArtSuggestionApplier.PromoAcceptKey(pg);
|
|
|
|
if(accepted.Contains(acceptKey)) continue;
|
|
|
|
Marechai.Server.Helpers.PendingImageStore.Delete(_assetRootPath, "software-promo-art", pg);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case SuggestionEntityType.SoftwareCover:
|
|
{
|
|
// Sweep every per-image pending file the suggester referenced that did NOT
|
|
// end up accepted. Same shape as the SoftwarePromoArt arm above with the
|
|
// entity-specific accept-key prefix and item folder.
|
|
if(!s.SuggestedValues.TryGetValue(
|
|
Suggestions.SoftwareCoverSuggestionApplier.FieldPhotos, out object photosRaw))
|
|
return;
|
|
|
|
if(photosRaw is not JsonElement arr || arr.ValueKind != JsonValueKind.Array) return;
|
|
|
|
foreach(JsonElement photo in arr.EnumerateArray())
|
|
{
|
|
if(photo.ValueKind != JsonValueKind.Object) continue;
|
|
if(!photo.TryGetProperty("guid", out JsonElement gj) ||
|
|
gj.ValueKind != JsonValueKind.String) continue;
|
|
if(!Guid.TryParse(gj.GetString(), out Guid pg)) continue;
|
|
|
|
string acceptKey = Suggestions.SoftwareCoverSuggestionApplier.CoverAcceptKey(pg);
|
|
|
|
if(accepted.Contains(acceptKey)) continue;
|
|
|
|
Marechai.Server.Helpers.PendingImageStore.Delete(_assetRootPath, "software-covers", pg);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case SuggestionEntityType.SoftwareScreenshot:
|
|
{
|
|
// Sweep every per-image pending file the suggester referenced that did NOT
|
|
// end up accepted. Same shape as the SoftwarePromoArt/SoftwareCover arms
|
|
// above with the entity-specific accept-key prefix and item folder.
|
|
if(!s.SuggestedValues.TryGetValue(
|
|
Suggestions.SoftwareScreenshotSuggestionApplier.FieldPhotos, out object photosRaw))
|
|
return;
|
|
|
|
if(photosRaw is not JsonElement arr || arr.ValueKind != JsonValueKind.Array) return;
|
|
|
|
foreach(JsonElement photo in arr.EnumerateArray())
|
|
{
|
|
if(photo.ValueKind != JsonValueKind.Object) continue;
|
|
if(!photo.TryGetProperty("guid", out JsonElement gj) ||
|
|
gj.ValueKind != JsonValueKind.String) continue;
|
|
if(!Guid.TryParse(gj.GetString(), out Guid pg)) continue;
|
|
|
|
string acceptKey = Suggestions.SoftwareScreenshotSuggestionApplier.ScreenshotAcceptKey(pg);
|
|
|
|
if(accepted.Contains(acceptKey)) continue;
|
|
|
|
Marechai.Server.Helpers.PendingImageStore.Delete(_assetRootPath, "software-screenshots", pg);
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignored — review must not fail because of pending-media cleanup
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────── Helpers ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Count the photos referenced by a GPU-photo batch suggestion's
|
|
/// <c>SuggestedValues["photos"]</c> array. Returns 0 when the array is missing or
|
|
/// malformed (defensive — the validator at submit time should catch malformed
|
|
/// payloads before persistence, but this guards against post-hoc DB hand-edits).
|
|
/// </summary>
|
|
static int CountSuggestedGpuPhotos(Suggestion s)
|
|
{
|
|
if(s?.SuggestedValues is null) return 0;
|
|
if(!s.SuggestedValues.TryGetValue(Suggestions.GpuPhotoSuggestionApplier.FieldPhotos, out object raw))
|
|
return 0;
|
|
|
|
return raw switch
|
|
{
|
|
JsonElement je when je.ValueKind == JsonValueKind.Array => je.GetArrayLength(),
|
|
System.Collections.ICollection col => col.Count,
|
|
System.Collections.IEnumerable enumerable => enumerable.Cast<object>().Count(),
|
|
_ => 0
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Count the photos referenced by a Processor-photo batch suggestion's
|
|
/// <c>SuggestedValues["photos"]</c> array. Returns 0 when the array is missing or
|
|
/// malformed (defensive — the validator at submit time should catch malformed
|
|
/// payloads before persistence, but this guards against post-hoc DB hand-edits).
|
|
/// </summary>
|
|
static int CountSuggestedProcessorPhotos(Suggestion s)
|
|
{
|
|
if(s?.SuggestedValues is null) return 0;
|
|
if(!s.SuggestedValues.TryGetValue(Suggestions.ProcessorPhotoSuggestionApplier.FieldPhotos, out object raw))
|
|
return 0;
|
|
|
|
return raw switch
|
|
{
|
|
JsonElement je when je.ValueKind == JsonValueKind.Array => je.GetArrayLength(),
|
|
System.Collections.ICollection col => col.Count,
|
|
System.Collections.IEnumerable enumerable => enumerable.Cast<object>().Count(),
|
|
_ => 0
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Count the photos referenced by a SoundSynth-photo batch suggestion's
|
|
/// <c>SuggestedValues["photos"]</c> array. Returns 0 when the array is missing or
|
|
/// malformed (defensive — the validator at submit time should catch malformed
|
|
/// payloads before persistence, but this guards against post-hoc DB hand-edits).
|
|
/// </summary>
|
|
static int CountSuggestedSoundSynthPhotos(Suggestion s)
|
|
{
|
|
if(s?.SuggestedValues is null) return 0;
|
|
if(!s.SuggestedValues.TryGetValue(Suggestions.SoundSynthPhotoSuggestionApplier.FieldPhotos, out object raw))
|
|
return 0;
|
|
|
|
return raw switch
|
|
{
|
|
JsonElement je when je.ValueKind == JsonValueKind.Array => je.GetArrayLength(),
|
|
System.Collections.ICollection col => col.Count,
|
|
System.Collections.IEnumerable enumerable => enumerable.Cast<object>().Count(),
|
|
_ => 0
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Count the photos referenced by a Machine-photo batch suggestion's
|
|
/// <c>SuggestedValues["photos"]</c> array. Returns 0 when the array is missing or
|
|
/// malformed (defensive — the validator at submit time should catch malformed
|
|
/// payloads before persistence, but this guards against post-hoc DB hand-edits).
|
|
/// </summary>
|
|
static int CountSuggestedMachinePhotos(Suggestion s)
|
|
{
|
|
if(s?.SuggestedValues is null) return 0;
|
|
if(!s.SuggestedValues.TryGetValue(Suggestions.MachinePhotoSuggestionApplier.FieldPhotos, out object raw))
|
|
return 0;
|
|
|
|
return raw switch
|
|
{
|
|
JsonElement je when je.ValueKind == JsonValueKind.Array => je.GetArrayLength(),
|
|
System.Collections.ICollection col => col.Count,
|
|
System.Collections.IEnumerable enumerable => enumerable.Cast<object>().Count(),
|
|
_ => 0
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Count the images referenced by a Software-promo-art batch suggestion's
|
|
/// <c>SuggestedValues["photos"]</c> array. Returns 0 when the array is missing or
|
|
/// malformed (defensive — the validator at submit time should catch malformed
|
|
/// payloads before persistence, but this guards against post-hoc DB hand-edits).
|
|
/// </summary>
|
|
static int CountSuggestedSoftwarePromoArt(Suggestion s)
|
|
{
|
|
if(s?.SuggestedValues is null) return 0;
|
|
if(!s.SuggestedValues.TryGetValue(Suggestions.SoftwarePromoArtSuggestionApplier.FieldPhotos, out object raw))
|
|
return 0;
|
|
|
|
return raw switch
|
|
{
|
|
JsonElement je when je.ValueKind == JsonValueKind.Array => je.GetArrayLength(),
|
|
System.Collections.ICollection col => col.Count,
|
|
System.Collections.IEnumerable enumerable => enumerable.Cast<object>().Count(),
|
|
_ => 0
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Count the images referenced by a Software-cover batch suggestion's
|
|
/// <c>SuggestedValues["photos"]</c> array. Returns 0 when the array is missing or
|
|
/// malformed (defensive — the validator at submit time should catch malformed
|
|
/// payloads before persistence, but this guards against post-hoc DB hand-edits).
|
|
/// </summary>
|
|
static int CountSuggestedSoftwareCovers(Suggestion s)
|
|
{
|
|
if(s?.SuggestedValues is null) return 0;
|
|
if(!s.SuggestedValues.TryGetValue(Suggestions.SoftwareCoverSuggestionApplier.FieldPhotos, out object raw))
|
|
return 0;
|
|
|
|
return raw switch
|
|
{
|
|
JsonElement je when je.ValueKind == JsonValueKind.Array => je.GetArrayLength(),
|
|
System.Collections.ICollection col => col.Count,
|
|
System.Collections.IEnumerable enumerable => enumerable.Cast<object>().Count(),
|
|
_ => 0
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Count the images referenced by a Software-screenshot batch suggestion's
|
|
/// <c>SuggestedValues["photos"]</c> array. Returns 0 when the array is missing or
|
|
/// malformed (defensive — the validator at submit time should catch malformed
|
|
/// payloads before persistence, but this guards against post-hoc DB hand-edits).
|
|
/// </summary>
|
|
static int CountSuggestedSoftwareScreenshots(Suggestion s)
|
|
{
|
|
if(s?.SuggestedValues is null) return 0;
|
|
if(!s.SuggestedValues.TryGetValue(Suggestions.SoftwareScreenshotSuggestionApplier.FieldPhotos, out object raw))
|
|
return 0;
|
|
|
|
return raw switch
|
|
{
|
|
JsonElement je when je.ValueKind == JsonValueKind.Array => je.GetArrayLength(),
|
|
System.Collections.ICollection col => col.Count,
|
|
System.Collections.IEnumerable enumerable => enumerable.Cast<object>().Count(),
|
|
_ => 0
|
|
};
|
|
}
|
|
|
|
async Task<bool> IsInAnyRoleAsync(string userId, params string[] roles)
|
|
{
|
|
ApplicationUser u = await userManager.FindByIdAsync(userId);
|
|
if(u is null) return false;
|
|
|
|
foreach(string role in roles)
|
|
if(await userManager.IsInRoleAsync(u, role)) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Best-effort coercion of a raw JSON-deserialised value to a string suitable for
|
|
/// "is this empty" validation. Mirrors the per-applier <c>ToStringValue</c> helpers
|
|
/// but kept here so the controller stays self-contained for validation.
|
|
/// </summary>
|
|
static string ExtractStringForValidation(object v)
|
|
{
|
|
return v switch
|
|
{
|
|
null => null,
|
|
JsonElement je => je.ValueKind switch
|
|
{
|
|
JsonValueKind.Null => null,
|
|
JsonValueKind.String => je.GetString(),
|
|
_ => je.ToString()
|
|
},
|
|
string s => s,
|
|
_ => v.ToString()
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Best-effort coercion of a raw JSON-deserialised value to an <see cref="int" />
|
|
/// for "is this a defined enum value" validation. Returns <c>true</c> when a
|
|
/// numeric value could be extracted; the caller is responsible for the
|
|
/// <see cref="Enum.IsDefined(System.Type, object)" /> check on the result.
|
|
/// </summary>
|
|
static bool TryExtractIntForValidation(object v, out int value)
|
|
{
|
|
value = 0;
|
|
switch(v)
|
|
{
|
|
case null: return false;
|
|
case int i: value = i; return true;
|
|
case short s: value = s; return true;
|
|
case long l when l >= int.MinValue && l <= int.MaxValue: value = (int)l; return true;
|
|
case byte b: value = b; return true;
|
|
case JsonElement je:
|
|
if(je.ValueKind == JsonValueKind.Number && je.TryGetInt32(out int p)) { value = p; return true; }
|
|
if(je.ValueKind == JsonValueKind.String && int.TryParse(je.GetString(), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int ps)) { value = ps; return true; }
|
|
return false;
|
|
case string str: return int.TryParse(str, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out value);
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
async Task DispatchReviewMessageAsync(Suggestion s, string entityDisplayName,
|
|
int acceptedCount, int suggestedCount,
|
|
bool roleGranted, bool wasAddition)
|
|
{
|
|
string subject;
|
|
string body;
|
|
string entityLabel = EntityLabel(s.EntityType);
|
|
|
|
// Per-subkey suggestions get a parenthesised secondary tag (e.g. "(Spanish description)")
|
|
// so the recipient can tell which language was reviewed.
|
|
string secondaryLabel = await GetEntitySecondaryLabelAsync(s.EntityType, s.Subkey);
|
|
string entitySuffix = string.IsNullOrEmpty(secondaryLabel) ? string.Empty : " " + secondaryLabel;
|
|
|
|
// Reference text — clickable markdown link when the entity exists, fallback to the
|
|
// payload-derived name (or just the entity-type label) when it doesn't.
|
|
string entityRef;
|
|
|
|
if(s.EntityId.HasValue)
|
|
entityRef = EntityLink(s.EntityType, s.EntityId.Value, entityDisplayName);
|
|
else if(!string.IsNullOrWhiteSpace(entityDisplayName))
|
|
entityRef = $"**{entityDisplayName}**";
|
|
else
|
|
entityRef = entityLabel;
|
|
|
|
// GPU-photo batch suggestions get a per-photo summary instead of the generic
|
|
// "X of Y suggested change(s)" wording. The total photo count comes from the
|
|
// suggested 'photos' array; the accepted count comes from the AppliedFields keys
|
|
// (only photo.<guid> keys count — license_id / source_url are batch-level and
|
|
// applied implicitly with each accepted photo).
|
|
if(s.EntityType == SuggestionEntityType.GpuPhoto)
|
|
{
|
|
int photoTotal = CountSuggestedGpuPhotos(s);
|
|
int photoAccepted = (s.AppliedFields ?? new Dictionary<string, string>())
|
|
.Count(kv => kv.Key.StartsWith("photo.", StringComparison.Ordinal));
|
|
|
|
string plural = photoTotal == 1 ? "photo" : "photos";
|
|
|
|
if(photoAccepted == 0)
|
|
{
|
|
subject = "Your GPU photo upload was not accepted";
|
|
body =
|
|
$"Your suggested upload of {photoTotal} {plural} for {entityRef} was reviewed but no photos were accepted. " +
|
|
$"Thank you for contributing — feel free to refine and try again.";
|
|
}
|
|
else if(photoAccepted == photoTotal)
|
|
{
|
|
subject = "Your GPU photo upload was accepted";
|
|
body =
|
|
$"Your suggested upload of {photoTotal} {plural} for {entityRef} was accepted. Thank you!";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
else
|
|
{
|
|
subject = "Your GPU photo upload was partially accepted";
|
|
body =
|
|
$"Your suggested upload for {entityRef} was reviewed. " +
|
|
$"{photoAccepted} of {photoTotal} {plural} were accepted; the rest were declined.";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.ProcessorPhoto)
|
|
{
|
|
int photoTotal = CountSuggestedProcessorPhotos(s);
|
|
int photoAccepted = (s.AppliedFields ?? new Dictionary<string, string>())
|
|
.Count(kv => kv.Key.StartsWith("photo.", StringComparison.Ordinal));
|
|
|
|
string plural = photoTotal == 1 ? "photo" : "photos";
|
|
|
|
if(photoAccepted == 0)
|
|
{
|
|
subject = "Your processor photo upload was not accepted";
|
|
body =
|
|
$"Your suggested upload of {photoTotal} {plural} for {entityRef} was reviewed but no photos were accepted. " +
|
|
$"Thank you for contributing — feel free to refine and try again.";
|
|
}
|
|
else if(photoAccepted == photoTotal)
|
|
{
|
|
subject = "Your processor photo upload was accepted";
|
|
body =
|
|
$"Your suggested upload of {photoTotal} {plural} for {entityRef} was accepted. Thank you!";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
else
|
|
{
|
|
subject = "Your processor photo upload was partially accepted";
|
|
body =
|
|
$"Your suggested upload for {entityRef} was reviewed. " +
|
|
$"{photoAccepted} of {photoTotal} {plural} were accepted; the rest were declined.";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.SoundSynthPhoto)
|
|
{
|
|
int photoTotal = CountSuggestedSoundSynthPhotos(s);
|
|
int photoAccepted = (s.AppliedFields ?? new Dictionary<string, string>())
|
|
.Count(kv => kv.Key.StartsWith("photo.", StringComparison.Ordinal));
|
|
|
|
string plural = photoTotal == 1 ? "photo" : "photos";
|
|
|
|
if(photoAccepted == 0)
|
|
{
|
|
subject = "Your sound synth photo upload was not accepted";
|
|
body =
|
|
$"Your suggested upload of {photoTotal} {plural} for {entityRef} was reviewed but no photos were accepted. " +
|
|
$"Thank you for contributing — feel free to refine and try again.";
|
|
}
|
|
else if(photoAccepted == photoTotal)
|
|
{
|
|
subject = "Your sound synth photo upload was accepted";
|
|
body =
|
|
$"Your suggested upload of {photoTotal} {plural} for {entityRef} was accepted. Thank you!";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
else
|
|
{
|
|
subject = "Your sound synth photo upload was partially accepted";
|
|
body =
|
|
$"Your suggested upload for {entityRef} was reviewed. " +
|
|
$"{photoAccepted} of {photoTotal} {plural} were accepted; the rest were declined.";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.MachinePhoto)
|
|
{
|
|
int photoTotal = CountSuggestedMachinePhotos(s);
|
|
int photoAccepted = (s.AppliedFields ?? new Dictionary<string, string>())
|
|
.Count(kv => kv.Key.StartsWith("photo.", StringComparison.Ordinal));
|
|
|
|
string plural = photoTotal == 1 ? "photo" : "photos";
|
|
|
|
if(photoAccepted == 0)
|
|
{
|
|
subject = "Your machine photo upload was not accepted";
|
|
body =
|
|
$"Your suggested upload of {photoTotal} {plural} for {entityRef} was reviewed but no photos were accepted. " +
|
|
$"Thank you for contributing — feel free to refine and try again.";
|
|
}
|
|
else if(photoAccepted == photoTotal)
|
|
{
|
|
subject = "Your machine photo upload was accepted";
|
|
body =
|
|
$"Your suggested upload of {photoTotal} {plural} for {entityRef} was accepted. Thank you!";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
else
|
|
{
|
|
subject = "Your machine photo upload was partially accepted";
|
|
body =
|
|
$"Your suggested upload for {entityRef} was reviewed. " +
|
|
$"{photoAccepted} of {photoTotal} {plural} were accepted; the rest were declined.";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.SoftwarePromoArt)
|
|
{
|
|
int photoTotal = CountSuggestedSoftwarePromoArt(s);
|
|
int photoAccepted = (s.AppliedFields ?? new Dictionary<string, string>())
|
|
.Count(kv => kv.Key.StartsWith("promo.", StringComparison.Ordinal));
|
|
|
|
string plural = photoTotal == 1 ? "image" : "images";
|
|
|
|
if(photoAccepted == 0)
|
|
{
|
|
subject = "Your software promo art upload was not accepted";
|
|
body =
|
|
$"Your suggested upload of {photoTotal} promo art {plural} for {entityRef} was reviewed but no images were accepted. " +
|
|
$"Thank you for contributing — feel free to refine and try again.";
|
|
}
|
|
else if(photoAccepted == photoTotal)
|
|
{
|
|
subject = "Your software promo art upload was accepted";
|
|
body =
|
|
$"Your suggested upload of {photoTotal} promo art {plural} for {entityRef} was accepted. Thank you!";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
else
|
|
{
|
|
subject = "Your software promo art upload was partially accepted";
|
|
body =
|
|
$"Your suggested upload for {entityRef} was reviewed. " +
|
|
$"{photoAccepted} of {photoTotal} promo art {plural} were accepted; the rest were declined.";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.SoftwareScreenshot)
|
|
{
|
|
int screenshotTotal = CountSuggestedSoftwareScreenshots(s);
|
|
int screenshotAccepted = (s.AppliedFields ?? new Dictionary<string, string>())
|
|
.Count(kv => kv.Key.StartsWith("screenshot.", StringComparison.Ordinal));
|
|
|
|
string plural = screenshotTotal == 1 ? "image" : "images";
|
|
|
|
if(screenshotAccepted == 0)
|
|
{
|
|
subject = "Your software screenshot upload was not accepted";
|
|
body =
|
|
$"Your suggested upload of {screenshotTotal} screenshot {plural} for {entityRef} was reviewed but no images were accepted. " +
|
|
$"Thank you for contributing — feel free to refine and try again.";
|
|
}
|
|
else if(screenshotAccepted == screenshotTotal)
|
|
{
|
|
subject = "Your software screenshot upload was accepted";
|
|
body =
|
|
$"Your suggested upload of {screenshotTotal} screenshot {plural} for {entityRef} was accepted. Thank you!";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
else
|
|
{
|
|
subject = "Your software screenshot upload was partially accepted";
|
|
body =
|
|
$"Your suggested upload for {entityRef} was reviewed. " +
|
|
$"{screenshotAccepted} of {screenshotTotal} screenshot {plural} were accepted; the rest were declined.";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
}
|
|
else if(s.EntityType == SuggestionEntityType.SoftwareCover)
|
|
{
|
|
int coverTotal = CountSuggestedSoftwareCovers(s);
|
|
int coverAccepted = (s.AppliedFields ?? new Dictionary<string, string>())
|
|
.Count(kv => kv.Key.StartsWith("cover.", StringComparison.Ordinal));
|
|
|
|
string plural = coverTotal == 1 ? "image" : "images";
|
|
|
|
if(coverAccepted == 0)
|
|
{
|
|
subject = "Your software cover upload was not accepted";
|
|
body =
|
|
$"Your suggested upload of {coverTotal} cover {plural} for {entityRef} was reviewed but no images were accepted. " +
|
|
$"Thank you for contributing — feel free to refine and try again.";
|
|
}
|
|
else if(coverAccepted == coverTotal)
|
|
{
|
|
subject = "Your software cover upload was accepted";
|
|
body =
|
|
$"Your suggested upload of {coverTotal} cover {plural} for {entityRef} was accepted. Thank you!";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
else
|
|
{
|
|
subject = "Your software cover upload was partially accepted";
|
|
body =
|
|
$"Your suggested upload for {entityRef} was reviewed. " +
|
|
$"{coverAccepted} of {coverTotal} cover {plural} were accepted; the rest were declined.";
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
}
|
|
else if(wasAddition)
|
|
{
|
|
// Addition flow: name-mandatory, so partial acceptance is impossible.
|
|
if(acceptedCount == 0)
|
|
{
|
|
subject = $"Your new {entityLabel} suggestion was not accepted";
|
|
body = $"Your suggestion to add a new {entityLabel} {entityRef} was not accepted. " +
|
|
$"Thank you for contributing — feel free to refine and try again.";
|
|
}
|
|
else
|
|
{
|
|
subject = $"Your new {entityLabel} suggestion was accepted";
|
|
body = $"Your suggestion to add a new {entityLabel} {entityRef} was accepted. Thank you!";
|
|
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
}
|
|
else if(acceptedCount == 0)
|
|
{
|
|
subject = "Your suggestion was not accepted";
|
|
body =
|
|
$"Your suggestion for {entityLabel} {entityRef}{entitySuffix} was reviewed but no fields were accepted. " +
|
|
$"Thank you for contributing — feel free to refine and try again.";
|
|
}
|
|
else if(acceptedCount == suggestedCount)
|
|
{
|
|
subject = "Your suggestion was accepted";
|
|
body =
|
|
$"Your suggestion for {entityLabel} {entityRef}{entitySuffix} was accepted. Thank you!";
|
|
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
else
|
|
{
|
|
subject = "Your suggestion was partially accepted";
|
|
body =
|
|
$"Your suggestion for {entityLabel} {entityRef}{entitySuffix} was reviewed. " +
|
|
$"{acceptedCount} of {suggestedCount} suggested change(s) were applied; the rest were declined.";
|
|
|
|
if(roleGranted) body += "\n\nYou are now a Collaborator!";
|
|
}
|
|
|
|
// Surface the admin's optional review comment (e.g. reason for rejection) as a
|
|
// markdown blockquote so the recipient sees it inline in their inbox.
|
|
if(!string.IsNullOrWhiteSpace(s.AdminReviewComment))
|
|
{
|
|
string quoted = string.Join("\n", s.AdminReviewComment.Split('\n').Select(l => "> " + l));
|
|
body += $"\n\n**Reviewer comment:**\n\n{quoted}";
|
|
}
|
|
|
|
await Marechai.Database.Helpers.MessageDispatcher.PostSystemMessageAsync(context,
|
|
new[] { s.CreatedById },
|
|
subject,
|
|
body);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the public-facing URL for an entity (Phase 1: Company only). Future entity
|
|
/// types add their own case here. Returns <c>null</c> when no public route is known —
|
|
/// the caller falls back to a non-link tag.
|
|
/// </summary>
|
|
static string GetEntityUrl(SuggestionEntityType type, long entityId) => type switch
|
|
{
|
|
SuggestionEntityType.Company => $"/company/{entityId}",
|
|
SuggestionEntityType.CompanyDescription => $"/company/{entityId}",
|
|
SuggestionEntityType.Machine => $"/machine/{entityId}",
|
|
SuggestionEntityType.MachineDescription => $"/machine/{entityId}",
|
|
SuggestionEntityType.Book => $"/book/{entityId}",
|
|
SuggestionEntityType.BookSynopsis => $"/book/{entityId}",
|
|
SuggestionEntityType.Document => $"/document/{entityId}",
|
|
SuggestionEntityType.DocumentSynopsis => $"/document/{entityId}",
|
|
SuggestionEntityType.Magazine => $"/magazine/{entityId}",
|
|
SuggestionEntityType.MagazineSynopsis => $"/magazine/{entityId}",
|
|
SuggestionEntityType.MagazineIssue => $"/magazine/issue/{entityId}",
|
|
SuggestionEntityType.Gpu => $"/gpu/{entityId}",
|
|
SuggestionEntityType.GpuDescription => $"/gpu/{entityId}",
|
|
SuggestionEntityType.GpuPhoto => $"/gpu/{entityId}",
|
|
SuggestionEntityType.Processor => $"/processor/{entityId}",
|
|
SuggestionEntityType.ProcessorDescription => $"/processor/{entityId}",
|
|
SuggestionEntityType.ProcessorPhoto => $"/processor/{entityId}",
|
|
SuggestionEntityType.SoundSynth => $"/soundsynth/{entityId}",
|
|
SuggestionEntityType.SoundSynthDescription => $"/soundsynth/{entityId}",
|
|
SuggestionEntityType.SoundSynthPhoto => $"/soundsynth/{entityId}",
|
|
SuggestionEntityType.MachinePhoto => $"/machine/{entityId}",
|
|
SuggestionEntityType.Person => $"/person/{entityId}",
|
|
SuggestionEntityType.PersonDescription => $"/person/{entityId}",
|
|
SuggestionEntityType.Software => $"/software/{entityId}",
|
|
SuggestionEntityType.SoftwareDescription => $"/software/{entityId}",
|
|
SuggestionEntityType.SoftwareRelease => $"/software/release/{entityId}",
|
|
// SoftwareVersion has no dedicated public page (versions render inline on the
|
|
// parent Software view). We don't know the parent SoftwareId at link-construction
|
|
// time without an extra DB lookup, so return null and let EntityLink fall back to
|
|
// the readable `'{name}' (#{id})` form in notification messages. The user can
|
|
// navigate to the Versions card on the parent Software view manually.
|
|
SuggestionEntityType.SoftwareVersion => null,
|
|
SuggestionEntityType.SoftwarePromoArt => $"/software/{entityId}",
|
|
SuggestionEntityType.SoftwareCover => $"/software/release/{entityId}",
|
|
SuggestionEntityType.SoftwareScreenshot => $"/software/{entityId}",
|
|
SuggestionEntityType.GpuVideo => $"/gpu/{entityId}",
|
|
SuggestionEntityType.ProcessorVideo => $"/processor/{entityId}",
|
|
SuggestionEntityType.SoundSynthVideo => $"/soundsynth/{entityId}",
|
|
SuggestionEntityType.SoftwareVideo => $"/software/{entityId}",
|
|
SuggestionEntityType.MachineVideo => $"/machine/{entityId}",
|
|
_ => null
|
|
};
|
|
|
|
/// <summary>
|
|
/// Markdown link to the entity's public page (e.g. <c>[Apple](/company/355)</c>) when a
|
|
/// public route exists, falling back to a parenthesised id when it doesn't.
|
|
/// </summary>
|
|
static string EntityLink(SuggestionEntityType type, long entityId, string displayName)
|
|
{
|
|
string label = string.IsNullOrWhiteSpace(displayName) ? $"#{entityId}" : displayName;
|
|
string url = GetEntityUrl(type, entityId);
|
|
return url is null ? $"'{label}' (#{entityId})" : $"[{label}]({url})";
|
|
}
|
|
|
|
/// <summary>Friendly lower-case label for the entity type used in message text.</summary>
|
|
static string EntityLabel(SuggestionEntityType type) => type switch
|
|
{
|
|
SuggestionEntityType.Company => "company",
|
|
SuggestionEntityType.CompanyDescription => "company description",
|
|
SuggestionEntityType.Machine => "machine",
|
|
SuggestionEntityType.MachineDescription => "machine description",
|
|
SuggestionEntityType.BookSynopsis => "book synopsis",
|
|
SuggestionEntityType.DocumentSynopsis => "document synopsis",
|
|
SuggestionEntityType.MagazineSynopsis => "magazine synopsis",
|
|
SuggestionEntityType.GpuDescription => "GPU description",
|
|
SuggestionEntityType.GpuPhoto => "GPU photo upload",
|
|
SuggestionEntityType.ProcessorPhoto => "processor photo upload",
|
|
SuggestionEntityType.SoundSynthPhoto => "sound synth photo upload",
|
|
SuggestionEntityType.MachinePhoto => "machine photo upload",
|
|
SuggestionEntityType.SoftwarePromoArt => "software promo art upload",
|
|
SuggestionEntityType.SoftwareCover => "software cover upload",
|
|
SuggestionEntityType.SoftwareScreenshot => "software screenshot upload",
|
|
SuggestionEntityType.GpuVideo => "GPU video link",
|
|
SuggestionEntityType.ProcessorVideo => "processor video link",
|
|
SuggestionEntityType.SoundSynthVideo => "sound synth video link",
|
|
SuggestionEntityType.SoftwareVideo => "software video link",
|
|
SuggestionEntityType.MachineVideo => "machine video link",
|
|
SuggestionEntityType.ProcessorDescription => "processor description",
|
|
SuggestionEntityType.SoundSynthDescription => "sound synth description",
|
|
SuggestionEntityType.PersonDescription => "person biography",
|
|
SuggestionEntityType.MachineFamily => "machine family",
|
|
SuggestionEntityType.Processor => "processor",
|
|
SuggestionEntityType.Gpu => "GPU",
|
|
SuggestionEntityType.SoundSynth => "sound synth",
|
|
SuggestionEntityType.Software => "software",
|
|
SuggestionEntityType.SoftwareDescription => "software description",
|
|
SuggestionEntityType.SoftwareFamily => "software family",
|
|
SuggestionEntityType.SoftwareRelease => "software release",
|
|
SuggestionEntityType.SoftwareVersion => "software version",
|
|
SuggestionEntityType.Book => "book",
|
|
SuggestionEntityType.Document => "document",
|
|
SuggestionEntityType.Magazine => "magazine",
|
|
SuggestionEntityType.MagazineIssue => "magazine issue",
|
|
SuggestionEntityType.Person => "person",
|
|
SuggestionEntityType.Screen => "screen",
|
|
_ => type.ToString().ToLowerInvariant()
|
|
};
|
|
|
|
async Task<SuggestionDto> ProjectAsync(Suggestion s, string entityDisplayName)
|
|
{
|
|
await Task.CompletedTask;
|
|
|
|
return new SuggestionDto
|
|
{
|
|
Id = s.Id,
|
|
EntityType = s.EntityType,
|
|
EntityId = s.EntityId,
|
|
Subkey = s.Subkey,
|
|
EntityDisplayName = entityDisplayName,
|
|
Status = s.Status,
|
|
CreatedById = s.CreatedById,
|
|
CreatedByUserName = s.CreatedBy?.UserName,
|
|
CreatedByDisplayName = s.CreatedBy?.DisplayName,
|
|
CreatedOn = s.CreatedOn,
|
|
ReviewedById = s.ReviewedById,
|
|
ReviewedByDisplayName = s.ReviewedBy?.DisplayName,
|
|
ReviewedOn = s.ReviewedOn,
|
|
UserComment = s.UserComment,
|
|
AdminReviewComment = s.AdminReviewComment,
|
|
SuggestedValuesJson = SuggestionsHelper.SerializeValues(s.SuggestedValues),
|
|
AppliedFieldsJson = SuggestionsHelper.SerializeApplied(s.AppliedFields)
|
|
};
|
|
}
|
|
}
|