Añadir endpoint de campos para Gestiona y adaptar login GlobalLeaks a DPoP

- Añade GET /api/denuncias/{id}/gestiona-fields para exponer los campos diarios requeridos por la integración con Gestiona.
- Devuelve un DTO cerrado con fecha, canal, resumen, datos de hechos, protección, sexo y preferencias de notificación.
- Adapta el login contra GlobalLeaks al nuevo flujo DPoP exigido desde la versión 5.0.94.
- Genera proof DPoP con clave EC P-256 efímera y lo envía en la cabecera DPoP junto a X-Token.
- Mejora el mensaje de error cuando GlobalLeaks rechaza el proof DPoP.
This commit is contained in:
2026-07-09 14:55:29 +02:00
parent d84d41b0e0
commit b6c61238a5
47 changed files with 16592 additions and 719 deletions

View File

@@ -12,6 +12,7 @@ using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace ApiDenuncias.Services
@@ -106,17 +107,12 @@ namespace ApiDenuncias.Services
// Expedientes (file)
// =========================================================
public async Task<GestionaCreateFileResponse> CreateFileAsync(Guid procedureId, string subject, string documentSeries, string siaCode)
public async Task<GestionaCreateFileResponse> CreateFileAsync(string subject, string documentSeries, string siaCode)
{
_ = subject;
_ = documentSeries;
_ = siaCode;
var effectiveProcedureId = procedureId == Guid.Empty
? Guid.Parse("82722c9b-cecc-4299-8a7b-ce5abeb8170b")
: procedureId;
var url = await ResolveExternalProcedureCreateFileUrlAsync(effectiveProcedureId);
var url = await ResolveExternalProcedureCreateFileUrlAsync(siaCode);
using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(
@@ -138,24 +134,263 @@ namespace ApiDenuncias.Services
return new GestionaCreateFileResponse(fileUrl, fileOpenUrl);
}
private Task<string> ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId)
private async Task<string> ResolveExternalProcedureCreateFileUrlAsync(string siaCode)
{
var externalProcedureId = Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId)
? configuredExternalProcedureId
: procedureId;
var proceduresUrl = await ResolveRootLinkHrefAsync("vnd.gestiona.catalog-2015")
?? "/rest/catalog-2015/procedures";
var procedures = await GetContentArrayAsync(
proceduresUrl,
"application/vnd.gestiona.procedures-2015-page+json",
"GET catalogo de procedimientos Gestiona");
return Task.FromResult(
$"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{externalProcedureId}/create-file");
var procedure = SelectProcedure(procedures, siaCode);
var externalProceduresUrl = GetLinkHref(procedure, "external-procedures")
?? throw new InvalidOperationException(
"El procedimiento seleccionado no contiene link 'external-procedures'.");
var externalProcedures = await GetContentArrayAsync(
externalProceduresUrl,
"application/vnd.gestiona.external-procedures-2015-page+json",
"GET procedimientos externos Gestiona");
var externalProcedure = SelectExternalProcedure(externalProcedures, procedure, siaCode);
return GetLinkHref(externalProcedure, "create-file")
?? throw new InvalidOperationException(
"El procedimiento externo seleccionado no contiene link 'create-file'.");
}
private async Task<string?> ResolveRootLinkHrefAsync(string rel)
{
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest");
AddBasicHeaders(req);
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "GET /rest");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException($"ResolveRootLinkHrefAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
return ToAbsoluteGestionaHref(GetLinkHref(doc.RootElement, rel));
}
private async Task<List<JsonElement>> GetContentArrayAsync(string url, string accept, string operation)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(accept));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, operation);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException($"{operation}: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
var source = doc.RootElement.TryGetProperty("content", out var content) &&
content.ValueKind == JsonValueKind.Array
? content
: doc.RootElement;
if (source.ValueKind != JsonValueKind.Array)
{
return [];
}
return source.EnumerateArray().Select(item => item.Clone()).ToList();
}
private JsonElement SelectProcedure(IReadOnlyList<JsonElement> procedures, string siaCode)
{
if (procedures.Count == 0)
{
throw new InvalidOperationException("Gestiona no ha devuelto procedimientos en el catalogo.");
}
var configuredName = NormalizeKey(_opts.ProcedureName);
if (!string.IsNullOrWhiteSpace(configuredName))
{
var match = procedures.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "name")).Contains(configuredName, StringComparison.Ordinal) ||
NormalizeKey(GetJsonString(item, "title")).Contains(configuredName, StringComparison.Ordinal) ||
NormalizeKey(GetJsonString(item, "description")).Contains(configuredName, StringComparison.Ordinal));
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
throw new InvalidOperationException(
$"No se ha encontrado en Gestiona el procedimiento configurado '{_opts.ProcedureName}'. Procedimientos disponibles: {DescribeCatalogItems(procedures)}.");
}
var normalizedSia = NormalizeKey(siaCode);
if (!string.IsNullOrWhiteSpace(normalizedSia))
{
var match = procedures.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "sia_code")) == normalizedSia ||
NormalizeKey(GetJsonString(item, "code")) == normalizedSia);
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
if (procedures.Count == 1)
{
return procedures[0];
}
throw new InvalidOperationException(
"Falta configurar Gestiona:ProcedureName para seleccionar el procedimiento sin usar IDs fijos.");
}
private JsonElement SelectExternalProcedure(
IReadOnlyList<JsonElement> externalProcedures,
JsonElement selectedProcedure,
string siaCode)
{
var withCreateFile = externalProcedures
.Where(item => !string.IsNullOrWhiteSpace(GetLinkHref(item, "create-file")))
.ToList();
if (withCreateFile.Count == 0)
{
throw new InvalidOperationException("El procedimiento seleccionado no tiene procedimientos externos con link 'create-file'.");
}
var configuredSia = NormalizeKey(
string.IsNullOrWhiteSpace(_opts.ExternalProcedureSiaCode)
? siaCode
: _opts.ExternalProcedureSiaCode);
if (!string.IsNullOrWhiteSpace(configuredSia))
{
var match = withCreateFile.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "sia_code")) == configuredSia ||
NormalizeKey(GetJsonString(item, "code")) == configuredSia);
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
var configuredName = NormalizeKey(_opts.ExternalProcedureName);
if (!string.IsNullOrWhiteSpace(configuredName))
{
var match = withCreateFile.FirstOrDefault(item =>
NormalizeKey(GetJsonString(item, "name")).Contains(configuredName, StringComparison.Ordinal) ||
NormalizeKey(GetJsonString(item, "title")).Contains(configuredName, StringComparison.Ordinal));
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
var procedureId = GetJsonString(selectedProcedure, "id");
if (!string.IsNullOrWhiteSpace(procedureId))
{
var sameId = withCreateFile.FirstOrDefault(item =>
string.Equals(GetJsonString(item, "id"), procedureId, StringComparison.OrdinalIgnoreCase));
if (sameId.ValueKind != JsonValueKind.Undefined)
{
return sameId;
}
}
if (withCreateFile.Count == 1)
{
return withCreateFile[0];
}
throw new InvalidOperationException(
$"No se puede seleccionar de forma inequivoca el procedimiento externo. Configura Gestiona:ExternalProcedureName o Gestiona:ExternalProcedureSiaCode. Disponibles: {DescribeCatalogItems(withCreateFile)}.");
}
private async Task<string> ResolveAssignableGroupHrefAsync(string groupCode)
{
if (string.IsNullOrWhiteSpace(groupCode))
{
throw new InvalidOperationException("Debe indicarse el codigo funcional del grupo de Gestiona.");
}
var groupsUrl = await ResolveRootLinkHrefAsync("vnd.gestiona.files.assignees.groups")
?? "/rest/files/assignees/groups";
var groups = await GetContentArrayAsync(
groupsUrl,
"application/vnd.gestiona.groups-page+json",
"GET grupos asignables Gestiona");
var normalizedCode = NormalizeKey(groupCode);
foreach (var group in groups)
{
var code = NormalizeKey(GetJsonString(group, "code"));
var name = NormalizeKey(GetJsonString(group, "name"));
var title = NormalizeKey(GetJsonString(group, "title"));
if (code == normalizedCode ||
name == normalizedCode ||
title == normalizedCode ||
name.StartsWith(normalizedCode + " ", StringComparison.Ordinal) ||
title.StartsWith(normalizedCode + " ", StringComparison.Ordinal))
{
var href = ToAbsoluteGestionaHref(GetLinkHref(group, "self"));
if (!string.IsNullOrWhiteSpace(href))
{
return href!;
}
}
}
throw new InvalidOperationException(
$"No se ha encontrado en Gestiona el grupo asignable '{groupCode}'. Grupos disponibles: {DescribeCatalogItems(groups)}.");
}
private string? ToAbsoluteGestionaHref(string? href)
{
if (string.IsNullOrWhiteSpace(href))
{
return null;
}
if (Uri.TryCreate(href, UriKind.Absolute, out _))
{
return href;
}
return href.StartsWith("/", StringComparison.Ordinal)
? $"{_opts.ApiBase.TrimEnd('/')}{href}"
: href;
}
private static string DescribeCatalogItems(IEnumerable<JsonElement> items)
{
var values = items
.Select(item => FirstNonEmpty(
GetJsonString(item, "name"),
GetJsonString(item, "title"),
GetJsonString(item, "code"),
GetJsonString(item, "sia_code"),
GetJsonString(item, "id")))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Take(10)
.ToList();
return values.Count == 0 ? "(sin nombres disponibles)" : string.Join("; ", values);
}
public async Task OpenFileAsync(
string fileUrl,
string? fileOpenUrl,
Guid managementUnitGroupId,
Guid assignedGroupId,
string assignedGroupCode,
bool confidential,
string freeTitle,
string siaCode)
string freeTitle)
{
if (string.IsNullOrWhiteSpace(fileOpenUrl))
{
@@ -164,20 +399,24 @@ namespace ApiDenuncias.Services
}
var url = fileOpenUrl;
var assignedGroupHref = await ResolveAssignableGroupHrefAsync(assignedGroupCode);
var managementGroupCode = string.IsNullOrWhiteSpace(_opts.ManagementUnitGroupCode)
? "700"
: _opts.ManagementUnitGroupCode.Trim();
var managementGroupHref = await ResolveAssignableGroupHrefAsync(managementGroupCode);
var payload = new
{
free_title = freeTitle,
location = siaCode,
entry_date = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
confidential,
initial_assignation = new[]
{
new { rel = "group", href = $"{_opts.ApiBase}/rest/groups/{assignedGroupId}" }
new { rel = "group", href = assignedGroupHref }
},
links = new[]
{
new { rel = "management-unit-group", href = $"{_opts.ApiBase}/rest/groups/{managementUnitGroupId}" }
new { rel = "management-unit-group", href = managementGroupHref }
}
};
@@ -195,6 +434,67 @@ namespace ApiDenuncias.Services
throw new InvalidOperationException($"OpenFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
public async Task AssignFileAsync(string fileUrl, string assignedGroupCode)
{
if (string.IsNullOrWhiteSpace(fileUrl))
{
throw new InvalidOperationException("AssignFileAsync: falta la URL del expediente.");
}
if (string.IsNullOrWhiteSpace(assignedGroupCode))
{
throw new InvalidOperationException("AssignFileAsync: falta el grupo asignado.");
}
var assigneesUrl = await ResolveFileAssigneesUrlAsync(fileUrl);
var assignedGroupHref = await ResolveAssignableGroupHrefAsync(assignedGroupCode);
var payload = new
{
links = new[]
{
new { rel = "group", href = assignedGroupHref }
}
};
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.links");
using var req = new HttpRequestMessage(HttpMethod.Put, assigneesUrl)
{
Content = content
};
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.links"));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "PUT file assignees");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException($"AssignFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
}
private async Task<string> ResolveFileAssigneesUrlAsync(string fileUrl)
{
var normalizedFileUrl = fileUrl.TrimEnd('/');
using var req = new HttpRequestMessage(HttpMethod.Get, normalizedFileUrl);
AddTokenAndAccept(req, "application/vnd.gestiona.file+json", "2");
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, $"GET {normalizedFileUrl}");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ResolveFileAssigneesUrlAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
return ToAbsoluteGestionaHref(GetLinkHref(doc.RootElement, "assignees"))
?? $"{normalizedFileUrl}/assignees";
}
public async Task<Guid> CreateFolderAsync(string fileUrl, string folderName)
{
var endpoint = $"{fileUrl}/documents-and-folders";
@@ -336,16 +636,16 @@ namespace ApiDenuncias.Services
{
var filtro = new
{
result = new { max_results = 25 },
filter = new { nif }
nif
};
var jsonFiltro = JsonSerializer.Serialize(filtro);
var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(jsonFiltro));
var url = $"{_opts.ApiBase}/rest/thirds?filter-view={Uri.EscapeDataString(b64)}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/thirds");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.thirds-page+json");
req.Content = new StringContent(
JsonSerializer.Serialize(filtro),
Encoding.UTF8,
"application/vnd.gestiona.filter.thirds+json");
using var resp = await _http.SendAsync(req);
resp.EnsureSuccessStatusCode();
@@ -507,14 +807,18 @@ namespace ApiDenuncias.Services
throw new InvalidOperationException($"Error EnlazarTerceroExistenteAsync: {resp.StatusCode}\n{body}");
}
public async Task AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty)
public async Task<GestionaEnsureThirdResponse> AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty)
{
if (thirdParty is null)
throw new ArgumentNullException(nameof(thirdParty));
thirdParty = NormalizeThirdParty(thirdParty);
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(thirdParty.DocumentId)) return;
if (string.IsNullOrWhiteSpace(thirdParty.DocumentId))
{
return new GestionaEnsureThirdResponse(true, warnings);
}
var encontrado = await BuscarTerceroPorNifAsync(thirdParty.DocumentId);
@@ -525,19 +829,175 @@ namespace ApiDenuncias.Services
_logger.LogWarning(
"Se omite la creacion/enlace del tercero en Gestiona para el expediente {FileUrl}: datos identificativos incompletos.",
fileUrl);
return;
return new GestionaEnsureThirdResponse(true, warnings);
}
encontrado = await CrearTerceroAsync(thirdParty);
}
else if (thirdParty.Address?.HasAnyValue == true)
else
{
await TryEnsureThirdAddressAsync(encontrado.SelfHref, thirdParty.Address);
warnings.AddRange(await BuildThirdPartyDifferenceWarningsAsync(encontrado.SelfHref, thirdParty));
if (thirdParty.Address?.HasAnyValue == true)
{
await TryEnsureThirdAddressAsync(encontrado.SelfHref, thirdParty.Address);
}
}
var yaEnlazados = await ObtenerTercerosEnlazadosAsync(fileUrl);
if (!yaEnlazados.Contains(encontrado.SelfHref))
await EnlazarTerceroExistenteAsync(fileUrl, encontrado.SelfHref);
return new GestionaEnsureThirdResponse(true, warnings);
}
private async Task<List<string>> BuildThirdPartyDifferenceWarningsAsync(
string thirdSelfHref,
ThirdPartyIdentityData thirdParty)
{
var warnings = new List<string>();
var details = await GetThirdPartyDetailsAsync(thirdSelfHref);
if (details is null)
{
return warnings;
}
var gestionaType = GetJsonString(details.Value, "type");
var expectedType = thirdParty.IsLegalEntity ? "JURIDICAL" : "PHISIC";
AddThirdDifferenceWarning(warnings, "tipo de tercero", expectedType, gestionaType, normalizeAsCode: true);
if (thirdParty.IsLegalEntity)
{
AddThirdDifferenceWarning(
warnings,
"razon social",
thirdParty.BusinessName,
GetJsonString(details.Value, "business_name"));
}
else
{
AddThirdDifferenceWarning(
warnings,
"nombre",
thirdParty.FirstName,
GetJsonString(details.Value, "first_name"));
var gestionaLastName = string.Join(
' ',
new[]
{
GetJsonString(details.Value, "first_surname"),
GetJsonString(details.Value, "second_surname")
}.Where(value => !string.IsNullOrWhiteSpace(value)));
AddThirdDifferenceWarning(warnings, "apellidos", thirdParty.LastName, gestionaLastName);
}
AddThirdDifferenceWarning(warnings, "email", thirdParty.Email, GetJsonString(details.Value, "email"));
AddThirdDifferenceWarning(
warnings,
"pais del documento",
NormalizeCountryCode(thirdParty.CountryCode),
GetJsonString(details.Value, "nif_country"),
normalizeAsCode: true);
AddThirdDifferenceWarning(
warnings,
"canal de notificacion",
BuildNotificationChannel(thirdParty),
GetJsonString(details.Value, "notification_channel"),
normalizeAsCode: true);
if (warnings.Count > 0)
{
warnings.Insert(
0,
"El tercero ya existia en Gestiona y no se han sustituido sus datos. Si alguno debe cambiarse, actualizalo manualmente en Gestiona.");
}
return warnings;
}
private async Task<JsonElement?> GetThirdPartyDetailsAsync(string thirdSelfHref)
{
if (string.IsNullOrWhiteSpace(thirdSelfHref))
{
return null;
}
using var req = new HttpRequestMessage(HttpMethod.Get, thirdSelfHref);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.third+json; version=3"));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "GET third");
if (!resp.IsSuccessStatusCode)
{
return null;
}
var body = await resp.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
using var doc = JsonDocument.Parse(body);
return doc.RootElement.Clone();
}
private static void AddThirdDifferenceWarning(
List<string> warnings,
string fieldName,
string? complaintValue,
string? gestionaValue,
bool normalizeAsCode = false)
{
if (string.IsNullOrWhiteSpace(complaintValue))
{
return;
}
var left = normalizeAsCode
? NormalizeKey(complaintValue)
: NormalizeForComparison(complaintValue);
var right = normalizeAsCode
? NormalizeKey(gestionaValue)
: NormalizeForComparison(gestionaValue);
if (string.Equals(left, right, StringComparison.Ordinal))
{
return;
}
warnings.Add(
$"El campo {fieldName} no coincide con Gestiona (denuncia: '{FormatWarningValue(complaintValue)}'; Gestiona: '{FormatWarningValue(gestionaValue)}').");
}
private static string NormalizeForComparison(string? value)
{
return string.Join(
' ',
(value ?? string.Empty)
.Trim()
.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.ToUpperInvariant();
}
private static string FormatWarningValue(string? value)
{
var normalized = string.Join(
' ',
(value ?? string.Empty)
.Trim()
.Split(' ', StringSplitOptions.RemoveEmptyEntries));
if (string.IsNullOrWhiteSpace(normalized))
{
return "sin dato";
}
const int maxLength = 80;
return normalized.Length <= maxLength
? normalized
: normalized[..maxLength] + "...";
}
private static ThirdPartyIdentityData NormalizeThirdParty(ThirdPartyIdentityData thirdParty)
@@ -563,6 +1023,9 @@ namespace ApiDenuncias.Services
BusinessName = businessName,
Email = (thirdParty.Email ?? string.Empty).Trim(),
CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode.Trim(),
NotificationPreference = thirdParty.NotificationPreference ?? string.Empty,
ElectronicNotification = thirdParty.ElectronicNotification ?? string.Empty,
PostalNotificationPreference = thirdParty.PostalNotificationPreference ?? string.Empty,
Address = thirdParty.Address
};
}
@@ -577,6 +1040,9 @@ namespace ApiDenuncias.Services
BusinessName = string.Empty,
Email = string.Empty,
CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode,
NotificationPreference = thirdParty.NotificationPreference ?? string.Empty,
ElectronicNotification = thirdParty.ElectronicNotification ?? string.Empty,
PostalNotificationPreference = thirdParty.PostalNotificationPreference ?? string.Empty,
Address = null
};
}
@@ -718,6 +1184,51 @@ namespace ApiDenuncias.Services
return null;
}
public async Task<GestionaExpedienteInfo?> BuscarExpedientePorCodigoAsync(string codigoExpediente)
{
if (string.IsNullOrWhiteSpace(codigoExpediente))
{
return null;
}
var normalizedCode = codigoExpediente.Trim();
var json = await GetFilesAsync(new
{
code = normalizedCode
});
using var doc = JsonDocument.Parse(json);
if (!TryGetFilesContent(doc, out var content) || content.GetArrayLength() == 0)
{
return null;
}
GestionaExpedienteInfo? first = null;
foreach (var item in content.EnumerateArray())
{
var expediente = BuildExpedienteInfo(item);
if (expediente is null)
{
continue;
}
if (first is null)
{
first = expediente;
}
if (string.Equals(
expediente.CodigoExpediente?.Trim(),
normalizedCode,
StringComparison.OrdinalIgnoreCase))
{
return expediente;
}
}
return first;
}
public async Task<GestionaExpedienteInfo?> ObtenerExpedienteAsync(string fileUrl)
{
if (string.IsNullOrWhiteSpace(fileUrl))
@@ -797,13 +1308,78 @@ namespace ApiDenuncias.Services
}
public async Task<GestionaAuditInfo?> ObtenerUltimaAuditoriaExpedienteAsync(string fileUrl)
{
if (string.IsNullOrWhiteSpace(fileUrl))
{
return null;
}
var auditUrl = $"{fileUrl.TrimEnd('/')}/audit";
using var req = new HttpRequestMessage(HttpMethod.Get, auditUrl);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.audit.entries-page"));
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, $"GET {auditUrl}");
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
resp.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ObtenerUltimaAuditoriaExpedienteAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
}
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) ||
content.ValueKind != JsonValueKind.Array ||
content.GetArrayLength() == 0)
{
return null;
}
GestionaAuditInfo? latest = null;
foreach (var item in content.EnumerateArray())
{
var entryDate = GetJsonDateTimeOffset(item, "entry_date");
if (entryDate is null)
{
continue;
}
if (latest?.Fecha is not null && entryDate.Value <= latest.Fecha.Value)
{
continue;
}
latest = new GestionaAuditInfo
{
Fecha = entryDate,
Mensaje = GetJsonString(item, "message")
};
}
return latest;
}
public async Task<List<ExpedienteTerceroDto>> ObtenerExpedientesPorTerceroAsync(
string nif,
DateTimeOffset? desde = null,
DateTimeOffset? hasta = null,
int maxPages = 1,
int maxResults = 30,
int maxParallel = 6 // de momento NO se usa, dejamos la firma por compatibilidad
int maxParallel = 6
)
{
if (string.IsNullOrWhiteSpace(nif))
@@ -811,21 +1387,11 @@ namespace ApiDenuncias.Services
nif = nif.Trim().ToUpperInvariant();
_ = maxPages;
_ = maxParallel;
// 1) Localizar el tercero por NIF
var tercero = await BuscarTerceroPorNifAsync(nif);
if (string.IsNullOrEmpty(tercero.SelfHref))
return new List<ExpedienteTerceroDto>(); // no hay tercero => no hay expedientes
var resultados = new List<ExpedienteTerceroDto>();
var json = await GetFilesAsync(new
{
third_rest_link = new
{
rel = "third",
href = tercero.SelfHref
}
dni = nif
});
using var doc = JsonDocument.Parse(json);
@@ -841,20 +1407,15 @@ namespace ApiDenuncias.Services
break;
}
DateTimeOffset? creation = null;
if (item.TryGetProperty("creation_date", out var pCreation))
{
if (pCreation.ValueKind == JsonValueKind.Number &&
pCreation.TryGetInt64(out var ts))
{
creation = DateTimeOffset.FromUnixTimeSeconds(ts);
}
else if (pCreation.ValueKind == JsonValueKind.String &&
long.TryParse(pCreation.GetString(), out var tsString))
{
creation = DateTimeOffset.FromUnixTimeSeconds(tsString);
}
}
var creation = GetJsonDateTimeOffset(item, "creation_date");
var updated = GetFirstJsonDateTimeOffset(
item,
"update_date",
"updated_at",
"modification_date",
"modified_at",
"last_update",
"last_modified");
if (desde.HasValue && creation.HasValue && creation.Value < desde.Value)
continue;
@@ -905,14 +1466,67 @@ namespace ApiDenuncias.Services
FileUrl = fileUrl,
CodigoExpediente = code,
Asunto = asunto,
Procedimiento = procedureName,
FechaCreacion = creation,
FechaUltimaModificacion = updated ?? creation,
Estado = state
});
}
await EnrichExpedientesWithAuditAsync(resultados, maxParallel);
return resultados;
}
private async Task EnrichExpedientesWithAuditAsync(
IReadOnlyList<ExpedienteTerceroDto> expedientes,
int maxParallel)
{
if (expedientes.Count == 0)
{
return;
}
var parallelism = Math.Clamp(maxParallel, 1, 6);
using var gate = new SemaphoreSlim(parallelism, parallelism);
var tasks = expedientes
.Where(expediente => !string.IsNullOrWhiteSpace(expediente.FileUrl))
.Select(async expediente =>
{
await gate.WaitAsync();
try
{
var audit = await ObtenerUltimaAuditoriaExpedienteAsync(expediente.FileUrl);
if (audit is null)
{
return;
}
if (audit.Fecha is not null)
{
expediente.FechaUltimaModificacion = audit.Fecha;
}
if (!string.IsNullOrWhiteSpace(audit.Mensaje))
{
expediente.UltimaAuditoriaMensaje = audit.Mensaje;
}
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"No se ha podido consultar la auditoria del expediente {FileUrl}.",
expediente.FileUrl);
}
finally
{
gate.Release();
}
});
await Task.WhenAll(tasks);
}
private async Task TryEnsureThirdAddressAsync(string thirdSelfHref, ThirdPartyAddressData address)
{
if (!address.HasAnyValue)
@@ -1138,6 +1752,58 @@ namespace ApiDenuncias.Services
: null;
}
private static DateTimeOffset? GetFirstJsonDateTimeOffset(JsonElement item, params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
var value = GetJsonDateTimeOffset(item, propertyName);
if (value is not null)
{
return value;
}
}
return null;
}
private static DateTimeOffset? GetJsonDateTimeOffset(JsonElement item, string propertyName)
{
if (!item.TryGetProperty(propertyName, out var property))
{
return null;
}
if (property.ValueKind == JsonValueKind.Number &&
property.TryGetInt64(out var timestamp))
{
return DateTimeOffset.FromUnixTimeSeconds(timestamp);
}
if (property.ValueKind != JsonValueKind.String)
{
return null;
}
var raw = property.GetString();
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var timestampString))
{
return DateTimeOffset.FromUnixTimeSeconds(timestampString);
}
return DateTimeOffset.TryParse(
raw.Replace("Z", "+00:00", StringComparison.Ordinal),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed)
? parsed
: null;
}
private static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
@@ -1171,7 +1837,25 @@ namespace ApiDenuncias.Services
private static string BuildNotificationChannel(ThirdPartyIdentityData thirdParty)
{
_ = thirdParty;
var notificationText = NormalizeKey(string.Join(
' ',
thirdParty.NotificationPreference,
thirdParty.ElectronicNotification,
thirdParty.PostalNotificationPreference));
var requestsPostal =
notificationText.Contains("CORREO POSTAL", StringComparison.Ordinal) ||
notificationText.Contains("POSTAL", StringComparison.Ordinal);
var requestsElectronic =
notificationText.Contains("ELECTRONICA", StringComparison.Ordinal) ||
notificationText.Contains("TELEMATICA", StringComparison.Ordinal) ||
notificationText.Contains("ONLINE", StringComparison.Ordinal);
if (requestsPostal && thirdParty.Address?.HasAnyValue == true && !requestsElectronic)
{
return "PAPER";
}
return "TELEMATIC";
}