Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/shared/Jordnaer.Shared/UserSearch/IDataForsyningenClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,14 @@ public interface IDataForsyningenClient
[QueryUriFormat(UriFormat.Unescaped)]
Task<IApiResponse<IEnumerable<ZipCodeSearchResponse>>>
GetZipCodesWithinCircle([AliasAs("cirkel")] string? circle, CancellationToken cancellationToken = default);

/// <summary>
/// Searches for zip codes matching the given query string.
/// </summary>
/// <param name="query">City or area name to search for.</param>
/// <param name="cancellationToken"></param>
/// <returns>Matching zip code entries, ordered by relevance.</returns>
[Get("/postnumre")]
Task<IApiResponse<IEnumerable<ZipCodeSearchResponse>>>
SearchZipCodesAsync([AliasAs("q")] string query, CancellationToken cancellationToken = default);
}
4 changes: 2 additions & 2 deletions src/web/Jordnaer/Components/NotificationItem.razor
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
</MudText>
@if (!string.IsNullOrEmpty(Notification.Description))
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="notification-description-truncate">
<div class="notification-description-truncate mud-secondary-text mud-typography mud-typography-caption">
@Notification.Description
</MudText>
</div>
Comment thread
NielsPilgaard marked this conversation as resolved.
}
<MudText Typo="Typo.caption" Color="Color.Tertiary" Style="font-size: 0.7rem;">
@Notification.CreatedUtc.Humanize(utcDate: true, dateToCompareAgainst: DateTime.UtcNow, culture: DanishCulture)
Expand Down
1 change: 1 addition & 0 deletions src/web/Jordnaer/Components/NotificationItem.razor.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.notification-title {
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
Expand Down
30 changes: 26 additions & 4 deletions src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,31 @@
[Parameter]
public IEnumerable<GroupSlim>? Groups { get; set; }

/// <summary>
/// Additional markers (e.g. HJEM lokalafdelinger) merged into the map alongside regular groups.
/// Filtered client-side by the active name filter.
/// </summary>
[Parameter]
public IReadOnlyList<GroupMarkerData>? AdditionalMarkers { get; set; }

private List<GroupMarkerData>? _groupMarkers;
private IEnumerable<GroupSlim>? _previousGroups;
private string? _previousNameFilter;
private IReadOnlyList<GroupMarkerData>? _previousAdditionalMarkers;

protected override void OnParametersSet()
{
// Early return if Groups reference hasn't changed
if (ReferenceEquals(Groups, _previousGroups))
// Early return if Groups reference, name filter, and AdditionalMarkers reference haven't changed
if (ReferenceEquals(Groups, _previousGroups) && Filter.Name == _previousNameFilter && ReferenceEquals(AdditionalMarkers, _previousAdditionalMarkers))
{
return;
}

_previousNameFilter = Filter.Name;
_previousAdditionalMarkers = AdditionalMarkers;

// Only use zip-code-level coordinates to avoid exposing exact group locations
_groupMarkers = Groups?
var regularMarkers = Groups?
.Where(g => g.ZipCodeLatitude.HasValue && g.ZipCodeLongitude.HasValue)
.Select(g => new GroupMarkerData
{
Expand All @@ -80,7 +92,17 @@
Latitude = g.ZipCodeLatitude!.Value + ((HashCode.Combine(g.Id, 1) & 0x7FFFFFFF) % 10000 / 10000.0 - 0.5) * 0.003,
Longitude = g.ZipCodeLongitude!.Value + ((HashCode.Combine(g.Id, 2) & 0x7FFFFFFF) % 10000 / 10000.0 - 0.5) * 0.005,
})
.ToList();
?? Enumerable.Empty<GroupMarkerData>();

var filteredAdditional = AdditionalMarkers ?? [];
if (!string.IsNullOrWhiteSpace(Filter.Name))
{
filteredAdditional = filteredAdditional
.Where(m => m.Name.Contains(Filter.Name, StringComparison.OrdinalIgnoreCase))
.ToList();
}

_groupMarkers = regularMarkers.Concat(filteredAdditional).ToList();

_previousGroups = Groups;
}
Expand Down
116 changes: 116 additions & 0 deletions src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Jordnaer.Shared;
using MassTransit;
using OneOf;
using OneOf.Types;
using System.Text;
using System.Text.Json;

namespace Jordnaer.Features.HjemGroups;

public class HjemGroupAdminService(
BlobServiceClient blobServiceClient,
IDataForsyningenClient dataForsyningenClient,
IPublishEndpoint publishEndpoint,
ILogger<HjemGroupAdminService> logger)
{
private const string ContainerName = "hjemlo-groups";
private const string BlobName = "groups.json";

private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};

public async Task<OneOf<List<HjemGroupEntry>, HjemGroupLoadError>> LoadAsync(CancellationToken cancellationToken = default)
{
try
{
var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName);
if (!await containerClient.ExistsAsync(cancellationToken))
{
return new HjemGroupLoadError($"Blob container '{ContainerName}' does not exist.");
}

var blobClient = containerClient.GetBlobClient(BlobName);
if (!await blobClient.ExistsAsync(cancellationToken))
{
return new HjemGroupLoadError($"Blob '{BlobName}' does not exist in container '{ContainerName}'.");
}

var response = await blobClient.DownloadContentAsync(cancellationToken);
return JsonSerializer.Deserialize<List<HjemGroupEntry>>(
response.Value.Content.ToString(), JsonOptions) ?? [];
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load HJEM group entries from blob storage.");
return new HjemGroupLoadError(ex.Message);
}
}

public async Task<OneOf<Success, HjemGroupSaveError>> SaveAsync(List<HjemGroupEntry> entries, CancellationToken cancellationToken = default)
{
try
{
var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName);
await containerClient.CreateIfNotExistsAsync(PublicAccessType.None, cancellationToken: cancellationToken);

var blobClient = containerClient.GetBlobClient(BlobName);
var json = JsonSerializer.Serialize(entries, JsonOptions);

using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
await blobClient.UploadAsync(stream, overwrite: true, cancellationToken: cancellationToken);

await publishEndpoint.Publish(
new InvalidateCacheTags { Tags = [HjemGroupProvider.CacheTag] },
cancellationToken);

return new Success();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to save HJEM group entries.");
return new HjemGroupSaveError(ex.Message);
}
}

/// <summary>
/// Looks up coordinates and city/zip for a Danish city or area name via Dataforsyningen.
/// Returns null if not found.
/// </summary>
public async Task<GeocodeResult?> GeocodeAsync(string locationText, CancellationToken cancellationToken = default)
{
var response = await dataForsyningenClient.SearchZipCodesAsync(locationText, cancellationToken);

if (!response.IsSuccessStatusCode || response.Content is null)
{
return null;
}

var first = response.Content.FirstOrDefault();
if (first.Navn is null || first.Visueltcenter is not { Length: >= 2 })
{
return null;
}

// GeoJSON order: [longitude, latitude]
var longitude = (double)first.Visueltcenter[0];
var latitude = (double)first.Visueltcenter[1];

int? zipCode = null;
if (int.TryParse(first.Nr, out var parsedZip))
{
zipCode = parsedZip;
}

return new GeocodeResult(first.Navn, zipCode, latitude, longitude);
}

public sealed record GeocodeResult(string City, int? ZipCode, double Latitude, double Longitude);
}

public sealed record HjemGroupLoadError(string Message);
public sealed record HjemGroupSaveError(string Message);
15 changes: 15 additions & 0 deletions src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Jordnaer.Features.HjemGroups;

public record HjemGroupEntry
{
public required string Name { get; init; }
public required Uri WebsiteUrl { get; init; }
public string? City { get; init; }
public int? ZipCode { get; init; }
public required double Latitude { get; init; }
public required double Longitude { get; init; }
public required HjemGroupType Type { get; init; }
public string? IconUrl { get; init; }
}

public enum HjemGroupType { Lokalafdeling, Lokalrepresentant }
121 changes: 121 additions & 0 deletions src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using Azure.Storage.Blobs;
using Jordnaer.Features.Map;
using OneOf;
using OneOf.Types;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using ZiggyCreatures.Caching.Fusion;

namespace Jordnaer.Features.HjemGroups;

public interface IHjemGroupProvider
{
Task<OneOf<IReadOnlyList<GroupMarkerData>, Error>> GetMarkersAsync(CancellationToken cancellationToken = default);
}

public class HjemGroupProvider(
BlobServiceClient blobServiceClient,
IFusionCache fusionCache,
ILogger<HjemGroupProvider> logger) : IHjemGroupProvider
{
internal const string CacheTag = "hjem-groups";
private const string CacheKey = "HjemGroups:markers";
private const string ContainerName = "hjemlo-groups";
private const string BlobName = "groups.json";

private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};

public async Task<OneOf<IReadOnlyList<GroupMarkerData>, Error>> GetMarkersAsync(CancellationToken cancellationToken = default)
{
try
{
var markers = await fusionCache.GetOrSetAsync<IReadOnlyList<GroupMarkerData>>(
CacheKey,
async (_, innerToken) =>
{
var result = await LoadFromBlobAsync(innerToken);
return result.IsT0
? result.AsT0
: throw new InvalidOperationException("Failed to load HJEM group markers from blob storage.");
},
tags: [CacheTag],
token: cancellationToken);
return OneOf<IReadOnlyList<GroupMarkerData>, Error>.FromT0(markers);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
return new Error();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
NielsPilgaard marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private async Task<OneOf<IReadOnlyList<GroupMarkerData>, Error>> LoadFromBlobAsync(CancellationToken cancellationToken)
{
try
{
var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName);

if (!await containerClient.ExistsAsync(cancellationToken))
{
logger.LogError("Blob container '{ContainerName}' does not exist.", ContainerName);
return new Error();
}

var blobClient = containerClient.GetBlobClient(BlobName);

if (!await blobClient.ExistsAsync(cancellationToken))
{
logger.LogError("Blob '{BlobName}' does not exist in container '{ContainerName}'.", BlobName, ContainerName);
return new Error();
}

var response = await blobClient.DownloadContentAsync(cancellationToken);
var entries = JsonSerializer.Deserialize<List<HjemGroupEntry>>(
response.Value.Content.ToString(), JsonOptions);

if (entries is null or { Count: 0 })
{
return Array.Empty<GroupMarkerData>();
}

return entries.Select(MapToMarker).ToList();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to load HJEM group markers from blob storage.");
return new Error();
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private static GroupMarkerData MapToMarker(HjemGroupEntry entry)
{
var idBytes = MD5.HashData(Encoding.UTF8.GetBytes(entry.WebsiteUrl.ToString() + entry.Name));
var id = new Guid(idBytes);

var websiteUrl = entry.Type == HjemGroupType.Lokalrepresentant
? "https://www.hjemlo.dk/lokalrepraesentanter"
: entry.WebsiteUrl.ToString();

return new GroupMarkerData
{
Id = id,
Name = entry.Name,
ProfilePictureUrl = entry.IconUrl ?? "/images/partners/logo-hjem.avif",
WebsiteUrl = websiteUrl,
ShortDescription = entry.Type == HjemGroupType.Lokalafdeling
? "HJEM lokalafdeling"
: "HJEM lokalrepræsentant",
ZipCode = entry.ZipCode,
City = entry.City,
Latitude = entry.Latitude,
Longitude = entry.Longitude,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Jordnaer.Features.HjemGroups;

public static class WebApplicationBuilderExtensions
{
public static WebApplicationBuilder AddHjemGroupServices(this WebApplicationBuilder builder)
{
builder.Services.AddScoped<IHjemGroupProvider, HjemGroupProvider>();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
builder.Services.AddScoped<HjemGroupAdminService>();

return builder;
}
}
Loading