Compare commits
7 Commits
feat/googl
...
feat/prepr
| Author | SHA1 | Date | |
|---|---|---|---|
| 986c7efea6 | |||
| 8bcff96821 | |||
| 1ca6ab7117 | |||
| e81c9f42c9 | |||
| c527011646 | |||
| 0b7edb1b7f | |||
| dcfdce1ec6 |
35
README.md
35
README.md
@@ -82,6 +82,41 @@ The deploy workflow writes the remote `.env` file and syncs `deploy/compose.yml`
|
|||||||
before running the server deploy script.
|
before running the server deploy script.
|
||||||
Use the raw Resend API key value for `RESEND_API_KEY`, without a `Bearer ` prefix.
|
Use the raw Resend API key value for `RESEND_API_KEY`, without a `Bearer ` prefix.
|
||||||
|
|
||||||
|
## Preprod Observability
|
||||||
|
|
||||||
|
The optional observability overlay runs a self-hosted Grafana stack for preproduction:
|
||||||
|
|
||||||
|
- Grafana `13.0.1`: dashboards
|
||||||
|
- Prometheus `v3.11.3`: metrics and local alert rules
|
||||||
|
- Loki `3.7.1`: Docker/container logs
|
||||||
|
- Tempo `2.10.3`: traces
|
||||||
|
- Grafana Alloy `v1.16.0`: OTLP receiver and Docker log collector
|
||||||
|
|
||||||
|
Start the app with observability:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/compose.yml -f deploy/observability/compose.observability.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Grafana is exposed at:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
http://127.0.0.1:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Default credentials are `admin` / `admin` unless `GRAFANA_ADMIN_USER` and
|
||||||
|
`GRAFANA_ADMIN_PASSWORD` are set. Set `GRAFANA_HTTP_BIND=0.0.0.0` only when the
|
||||||
|
preprod network boundary is trusted or protected by a reverse proxy/VPN.
|
||||||
|
|
||||||
|
Set a non-default `GRAFANA_ADMIN_PASSWORD` before exposing Grafana outside the
|
||||||
|
host. Prometheus alert rules are provisioned under
|
||||||
|
`deploy/observability/prometheus/rules/`; notification delivery is intentionally
|
||||||
|
left to the preprod operations environment.
|
||||||
|
|
||||||
|
Set `ALERTMANAGER_WEBHOOK_URL` to route alerts to a private notification endpoint.
|
||||||
|
See `docs/OPERATIONS/observability-runbook.md` for bring-up, alert triage, and
|
||||||
|
the optional protected Caddy configuration for Grafana.
|
||||||
|
|
||||||
## Solution
|
## Solution
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
@@ -20,7 +21,10 @@ internal static class ApplicationRegistration
|
|||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
|
|
||||||
services.AddHealthChecks()
|
services.AddHealthChecks()
|
||||||
.AddDbContextCheck<AppDbContext>();
|
.AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy(), tags: ["live"])
|
||||||
|
.AddDbContextCheck<AppDbContext>("postgres", tags: ["ready"])
|
||||||
|
.AddCheck<LocalBlobStorageHealthCheck>("local_blob_storage", tags: ["ready"])
|
||||||
|
.AddCheck<EmailerConfigurationHealthCheck>("emailer_configuration", tags: ["ready"]);
|
||||||
|
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
services.AddScoped<AccessScopeService>();
|
services.AddScoped<AccessScopeService>();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
|
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||||
|
|
||||||
@@ -8,7 +9,8 @@ internal sealed class LocalBlobStorage(
|
|||||||
IWebHostEnvironment environment,
|
IWebHostEnvironment environment,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
IOptions<LocalBlobStorageOptions> options,
|
IOptions<LocalBlobStorageOptions> options,
|
||||||
ILogger<LocalBlobStorage> logger)
|
ILogger<LocalBlobStorage> logger,
|
||||||
|
SocializeMetrics metrics)
|
||||||
: IBlobStorage
|
: IBlobStorage
|
||||||
{
|
{
|
||||||
private const long MaxUploadSize = 10 * 1024 * 1024;
|
private const long MaxUploadSize = 10 * 1024 * 1024;
|
||||||
@@ -31,32 +33,51 @@ internal sealed class LocalBlobStorage(
|
|||||||
string contentType,
|
string contentType,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
stream.Position = 0;
|
try
|
||||||
|
|
||||||
if (stream.Length > MaxUploadSize)
|
|
||||||
{
|
{
|
||||||
logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize);
|
stream.Position = 0;
|
||||||
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ContentTypes.IsAllowed(contentType, stream))
|
if (stream.Length > MaxUploadSize)
|
||||||
|
{
|
||||||
|
logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize);
|
||||||
|
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ContentTypes.IsAllowed(contentType, stream))
|
||||||
|
{
|
||||||
|
logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType);
|
||||||
|
throw new InvalidOperationException("Unsupported file type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string relativePath = GetSafeRelativePath(containerName, blobName);
|
||||||
|
string filePath = Path.Combine(GetRootPath(), relativePath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath());
|
||||||
|
|
||||||
|
await using FileStream fileStream = File.Create(filePath);
|
||||||
|
await stream.CopyToAsync(fileStream, ct);
|
||||||
|
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
|
||||||
|
|
||||||
|
string fileUri = BuildPublicUrl(relativePath);
|
||||||
|
LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null);
|
||||||
|
metrics.RecordBlobStorageOperation("upload", true);
|
||||||
|
|
||||||
|
return fileUri;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
{
|
{
|
||||||
logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType);
|
metrics.RecordBlobStorageOperation("upload", false);
|
||||||
throw new InvalidOperationException("Unsupported file type.");
|
throw;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
metrics.RecordBlobStorageOperation("upload", false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
metrics.RecordBlobStorageOperation("upload", false);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
string relativePath = GetSafeRelativePath(containerName, blobName);
|
|
||||||
string filePath = Path.Combine(GetRootPath(), relativePath);
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath());
|
|
||||||
|
|
||||||
await using FileStream fileStream = File.Create(filePath);
|
|
||||||
await stream.CopyToAsync(fileStream, ct);
|
|
||||||
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
|
|
||||||
|
|
||||||
string fileUri = BuildPublicUrl(relativePath);
|
|
||||||
LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null);
|
|
||||||
|
|
||||||
return fileUri;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MemoryStream> DownloadFileAsync(
|
public async Task<MemoryStream> DownloadFileAsync(
|
||||||
@@ -64,19 +85,43 @@ internal sealed class LocalBlobStorage(
|
|||||||
string blobName,
|
string blobName,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
|
try
|
||||||
|
|
||||||
if (!File.Exists(filePath))
|
|
||||||
{
|
{
|
||||||
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName);
|
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName);
|
||||||
|
}
|
||||||
|
|
||||||
|
MemoryStream memoryStream = new();
|
||||||
|
await using FileStream fileStream = File.OpenRead(filePath);
|
||||||
|
await fileStream.CopyToAsync(memoryStream, ct);
|
||||||
|
memoryStream.Position = 0;
|
||||||
|
metrics.RecordBlobStorageOperation("download", true);
|
||||||
|
|
||||||
|
return memoryStream;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
metrics.RecordBlobStorageOperation("download", false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
metrics.RecordBlobStorageOperation("download", false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
metrics.RecordBlobStorageOperation("download", false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
metrics.RecordBlobStorageOperation("download", false);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
MemoryStream memoryStream = new();
|
|
||||||
await using FileStream fileStream = File.OpenRead(filePath);
|
|
||||||
await fileStream.CopyToAsync(memoryStream, ct);
|
|
||||||
memoryStream.Position = 0;
|
|
||||||
|
|
||||||
return memoryStream;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal string GetRootPath()
|
internal string GetRootPath()
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
||||||
|
|
||||||
internal class LoggerEmailSender(ILogger<IEmailSender> logger)
|
internal class LoggerEmailSender(
|
||||||
|
ILogger<IEmailSender> logger,
|
||||||
|
SocializeMetrics metrics)
|
||||||
: IEmailSender
|
: IEmailSender
|
||||||
{
|
{
|
||||||
private static readonly Action<ILogger, string, string, string, string, Exception?> LogDevelopmentEmail =
|
private static readonly Action<ILogger, string, string, string, string, Exception?> LogDevelopmentEmail =
|
||||||
@@ -14,6 +17,7 @@ internal class LoggerEmailSender(ILogger<IEmailSender> logger)
|
|||||||
public Task SendEmailAsync(string email, string subject, string message)
|
public Task SendEmailAsync(string email, string subject, string message)
|
||||||
{
|
{
|
||||||
LogDevelopmentEmail(logger, email, subject, Environment.NewLine, message, null);
|
LogDevelopmentEmail(logger, email, subject, Environment.NewLine, message, null);
|
||||||
|
metrics.RecordEmailDelivery("logger", true);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
||||||
@@ -11,13 +12,16 @@ internal class ResendEmailSender : IEmailSender
|
|||||||
{
|
{
|
||||||
private static readonly Uri EndpointUri = new("https://api.resend.com/emails");
|
private static readonly Uri EndpointUri = new("https://api.resend.com/emails");
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly SocializeMetrics _metrics;
|
||||||
private readonly EmailerOptions _options;
|
private readonly EmailerOptions _options;
|
||||||
|
|
||||||
public ResendEmailSender(
|
public ResendEmailSender(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<EmailerOptions> options)
|
IOptions<EmailerOptions> options,
|
||||||
|
SocializeMetrics metrics)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
|
_metrics = metrics;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
|
||||||
string apiKey = NormalizeApiKey(_options.ApiKey);
|
string apiKey = NormalizeApiKey(_options.ApiKey);
|
||||||
@@ -49,13 +53,33 @@ internal class ResendEmailSender : IEmailSender
|
|||||||
|
|
||||||
string json = JsonSerializer.Serialize(payload);
|
string json = JsonSerializer.Serialize(payload);
|
||||||
using StringContent content = new(json, Encoding.UTF8, "application/json");
|
using StringContent content = new(json, Encoding.UTF8, "application/json");
|
||||||
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
|
try
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
{
|
||||||
string body = await response.Content.ReadAsStringAsync();
|
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Resend email failed: {response.StatusCode} - {body}");
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string body = await response.Content.ReadAsStringAsync();
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Resend email failed: {response.StatusCode} - {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_metrics.RecordEmailDelivery("resend", true);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
_metrics.RecordEmailDelivery("resend", false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
_metrics.RecordEmailDelivery("resend", false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
_metrics.RecordEmailDelivery("resend", false);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
internal sealed class EmailerConfigurationHealthCheck(
|
||||||
|
IWebHostEnvironment environment,
|
||||||
|
IOptions<EmailerOptions> options)
|
||||||
|
: IHealthCheck
|
||||||
|
{
|
||||||
|
public Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
return Task.FromResult(HealthCheckResult.Healthy("Development email sender logs email instead of delivering it."));
|
||||||
|
}
|
||||||
|
|
||||||
|
EmailerOptions value = options.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(value.ApiKey) || string.IsNullOrWhiteSpace(value.FromEmail))
|
||||||
|
{
|
||||||
|
return Task.FromResult(HealthCheckResult.Unhealthy("Emailer API key or from address is missing."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(HealthCheckResult.Healthy("Emailer configuration is present."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
internal sealed class LocalBlobStorageHealthCheck(
|
||||||
|
LocalBlobStorage blobStorage,
|
||||||
|
IOptions<LocalBlobStorageOptions> options)
|
||||||
|
: IHealthCheck
|
||||||
|
{
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string rootPath = blobStorage.GetRootPath();
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.RequestPath))
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Local blob storage request path is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(rootPath);
|
||||||
|
string probePath = Path.Combine(rootPath, ".healthcheck");
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
probePath,
|
||||||
|
DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
cancellationToken);
|
||||||
|
File.Delete(probePath);
|
||||||
|
|
||||||
|
return HealthCheckResult.Healthy("Local blob storage is writable.");
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Local blob storage is not writable.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Npgsql;
|
||||||
|
using OpenTelemetry.Logs;
|
||||||
|
using OpenTelemetry.Metrics;
|
||||||
|
using OpenTelemetry.Resources;
|
||||||
|
using OpenTelemetry.Trace;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
internal static class ObservabilityRegistration
|
||||||
|
{
|
||||||
|
private const string DefaultServiceName = "socialize-api";
|
||||||
|
|
||||||
|
public static WebApplicationBuilder AddObservability(this WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
string serviceName = GetConfigurationValue(builder.Configuration, "OTEL_SERVICE_NAME", DefaultServiceName);
|
||||||
|
string serviceVersion = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown";
|
||||||
|
|
||||||
|
builder.Logging.Configure(options =>
|
||||||
|
{
|
||||||
|
options.ActivityTrackingOptions =
|
||||||
|
ActivityTrackingOptions.TraceId |
|
||||||
|
ActivityTrackingOptions.SpanId |
|
||||||
|
ActivityTrackingOptions.ParentId;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Logging.AddJsonConsole(options =>
|
||||||
|
{
|
||||||
|
options.IncludeScopes = true;
|
||||||
|
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||||
|
options.UseUtcTimestamp = true;
|
||||||
|
options.JsonWriterOptions = new JsonWriterOptions { Indented = false };
|
||||||
|
});
|
||||||
|
|
||||||
|
bool otlpEnabled = HasOtlpEndpoint(builder.Configuration);
|
||||||
|
if (otlpEnabled)
|
||||||
|
{
|
||||||
|
builder.Logging.AddOpenTelemetry(options =>
|
||||||
|
{
|
||||||
|
options.IncludeFormattedMessage = true;
|
||||||
|
options.IncludeScopes = true;
|
||||||
|
options.ParseStateValues = true;
|
||||||
|
options.SetResourceBuilder(BuildResource(serviceName, serviceVersion));
|
||||||
|
options.AddOtlpExporter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<SocializeMetrics>();
|
||||||
|
builder.Services.AddHostedService<WorkflowHealthSamplerService>();
|
||||||
|
builder.Services
|
||||||
|
.AddOpenTelemetry()
|
||||||
|
.ConfigureResource(resource => resource.AddService(
|
||||||
|
serviceName,
|
||||||
|
serviceVersion: serviceVersion))
|
||||||
|
.WithTracing(tracing =>
|
||||||
|
{
|
||||||
|
tracing
|
||||||
|
.AddSource(SocializeMetrics.ActivitySourceName)
|
||||||
|
.AddAspNetCoreInstrumentation(options =>
|
||||||
|
{
|
||||||
|
options.RecordException = true;
|
||||||
|
})
|
||||||
|
.AddHttpClientInstrumentation()
|
||||||
|
.AddNpgsql();
|
||||||
|
|
||||||
|
if (otlpEnabled)
|
||||||
|
{
|
||||||
|
tracing.AddOtlpExporter();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.WithMetrics(metrics =>
|
||||||
|
{
|
||||||
|
metrics
|
||||||
|
.AddMeter(SocializeMetrics.MeterName)
|
||||||
|
.AddAspNetCoreInstrumentation()
|
||||||
|
.AddHttpClientInstrumentation()
|
||||||
|
.AddRuntimeInstrumentation();
|
||||||
|
|
||||||
|
if (otlpEnabled)
|
||||||
|
{
|
||||||
|
metrics.AddOtlpExporter();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IApplicationBuilder UseObservabilityLoggingScope(this IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
return app.UseMiddleware<RequestLoggingScopeMiddleware>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapObservabilityHealthChecks(this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
endpoints.MapHealthChecks(
|
||||||
|
"/health",
|
||||||
|
new HealthCheckOptions { ResponseWriter = WriteHealthResponseAsync });
|
||||||
|
endpoints.MapHealthChecks(
|
||||||
|
"/health/live",
|
||||||
|
new HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = registration => registration.Tags.Contains("live", StringComparer.Ordinal),
|
||||||
|
ResponseWriter = WriteHealthResponseAsync,
|
||||||
|
});
|
||||||
|
endpoints.MapHealthChecks(
|
||||||
|
"/health/ready",
|
||||||
|
new HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = registration => registration.Tags.Contains("ready", StringComparer.Ordinal),
|
||||||
|
ResponseWriter = WriteHealthResponseAsync,
|
||||||
|
});
|
||||||
|
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceBuilder BuildResource(string serviceName, string serviceVersion)
|
||||||
|
{
|
||||||
|
return ResourceBuilder.CreateDefault().AddService(
|
||||||
|
serviceName,
|
||||||
|
serviceVersion: serviceVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasOtlpEndpoint(ConfigurationManager configuration)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]) ||
|
||||||
|
!string.IsNullOrWhiteSpace(configuration["Otlp:Endpoint"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetConfigurationValue(
|
||||||
|
ConfigurationManager configuration,
|
||||||
|
string key,
|
||||||
|
string fallback)
|
||||||
|
{
|
||||||
|
string? value = configuration[key];
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteHealthResponseAsync(HttpContext context, HealthReport report)
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
|
||||||
|
var response = new
|
||||||
|
{
|
||||||
|
status = report.Status.ToString(),
|
||||||
|
checks = report.Entries.Select(entry => new
|
||||||
|
{
|
||||||
|
name = entry.Key,
|
||||||
|
status = entry.Value.Status.ToString(),
|
||||||
|
description = entry.Value.Description,
|
||||||
|
duration = entry.Value.Duration.TotalMilliseconds,
|
||||||
|
}),
|
||||||
|
duration = report.TotalDuration.TotalMilliseconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
await JsonSerializer.SerializeAsync(
|
||||||
|
context.Response.Body,
|
||||||
|
response,
|
||||||
|
cancellationToken: context.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
internal sealed class RequestLoggingScopeMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
ILogger<RequestLoggingScopeMiddleware> logger)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
Dictionary<string, object?> scope = new()
|
||||||
|
{
|
||||||
|
["trace_id"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
|
||||||
|
["span_id"] = Activity.Current?.SpanId.ToString(),
|
||||||
|
["http.method"] = context.Request.Method,
|
||||||
|
["url.path"] = context.Request.Path.Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (context.User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
scope["user.id"] = context.User.GetUserId();
|
||||||
|
scope["user.email"] = context.User.GetEmail();
|
||||||
|
}
|
||||||
|
|
||||||
|
AddGuidIfPresent(scope, "organization.id", context, "organizationId");
|
||||||
|
AddGuidIfPresent(scope, "workspace.id", context, "workspaceId");
|
||||||
|
AddGuidIfPresent(scope, "client.id", context, "clientId");
|
||||||
|
AddGuidIfPresent(scope, "campaign.id", context, "campaignId");
|
||||||
|
AddGuidIfPresent(scope, "content_item.id", context, "contentItemId");
|
||||||
|
|
||||||
|
using IDisposable? _ = logger.BeginScope(scope);
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddGuidIfPresent(
|
||||||
|
Dictionary<string, object?> scope,
|
||||||
|
string scopeKey,
|
||||||
|
HttpContext context,
|
||||||
|
string requestKey)
|
||||||
|
{
|
||||||
|
string? value = GetRouteOrQueryValue(context, requestKey);
|
||||||
|
if (Guid.TryParse(value, out Guid id))
|
||||||
|
{
|
||||||
|
scope[scopeKey] = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetRouteOrQueryValue(HttpContext context, string key)
|
||||||
|
{
|
||||||
|
object? routeValue = context.Request.RouteValues[key];
|
||||||
|
if (routeValue is not null)
|
||||||
|
{
|
||||||
|
return Convert.ToString(routeValue, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.Request.Query.TryGetValue(key, out Microsoft.Extensions.Primitives.StringValues queryValue)
|
||||||
|
? queryValue.ToString()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
internal sealed class SocializeMetrics : IDisposable
|
||||||
|
{
|
||||||
|
public const string MeterName = "Socialize.Api";
|
||||||
|
public const string ActivitySourceName = "Socialize.Api";
|
||||||
|
|
||||||
|
private readonly Counter<long> _approvalDecisionCounter;
|
||||||
|
private readonly Counter<long> _backgroundJobRunCounter;
|
||||||
|
private readonly Counter<long> _blobStorageOperationCounter;
|
||||||
|
private readonly Counter<long> _commentCreatedCounter;
|
||||||
|
private readonly Counter<long> _contentItemCreatedCounter;
|
||||||
|
private readonly Counter<long> _emailDeliveryCounter;
|
||||||
|
private readonly Counter<long> _feedbackSubmittedCounter;
|
||||||
|
private readonly Counter<long> _loginAttemptCounter;
|
||||||
|
private readonly Counter<long> _organizationCreatedCounter;
|
||||||
|
private readonly Counter<long> _workspaceCreatedCounter;
|
||||||
|
private readonly Counter<long> _workspaceInviteCreatedCounter;
|
||||||
|
private readonly object _workflowHealthLock = new();
|
||||||
|
private WorkflowHealthSnapshot _workflowHealthSnapshot = WorkflowHealthSnapshot.Empty;
|
||||||
|
|
||||||
|
public SocializeMetrics()
|
||||||
|
{
|
||||||
|
Meter = new Meter(MeterName);
|
||||||
|
ActivitySource = new ActivitySource(ActivitySourceName);
|
||||||
|
|
||||||
|
_loginAttemptCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.login.attempts",
|
||||||
|
description: "Login attempts partitioned by outcome.");
|
||||||
|
_organizationCreatedCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.organizations.created",
|
||||||
|
description: "Organizations created.");
|
||||||
|
_workspaceCreatedCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.workspaces.created",
|
||||||
|
description: "Workspaces created.");
|
||||||
|
_contentItemCreatedCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.content_items.created",
|
||||||
|
description: "Content items created.");
|
||||||
|
_commentCreatedCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.comments.created",
|
||||||
|
description: "Comments created.");
|
||||||
|
_approvalDecisionCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.approval_decisions.submitted",
|
||||||
|
description: "Approval decisions submitted.");
|
||||||
|
_feedbackSubmittedCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.feedback.submitted",
|
||||||
|
description: "Feedback reports submitted.");
|
||||||
|
_workspaceInviteCreatedCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.workspace_invites.created",
|
||||||
|
description: "Workspace invites created.");
|
||||||
|
_emailDeliveryCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.email.delivery",
|
||||||
|
description: "Email delivery attempts partitioned by outcome and provider.");
|
||||||
|
_blobStorageOperationCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.blob_storage.operations",
|
||||||
|
description: "Blob storage operations partitioned by operation and outcome.");
|
||||||
|
_backgroundJobRunCounter = Meter.CreateCounter<long>(
|
||||||
|
"socialize.background_job.runs",
|
||||||
|
description: "Background job runs partitioned by job and outcome.");
|
||||||
|
|
||||||
|
Meter.CreateObservableGauge(
|
||||||
|
"socialize.workflow.content_items",
|
||||||
|
ObserveContentItemCounts,
|
||||||
|
description: "Current content item counts by status.");
|
||||||
|
Meter.CreateObservableGauge(
|
||||||
|
"socialize.workflow.feedback_reports",
|
||||||
|
ObserveFeedbackReportCounts,
|
||||||
|
description: "Current feedback report counts by status.");
|
||||||
|
Meter.CreateObservableGauge(
|
||||||
|
"socialize.workflow.pending_invites",
|
||||||
|
ObservePendingInviteCount,
|
||||||
|
description: "Current pending workspace invite count.");
|
||||||
|
Meter.CreateObservableGauge(
|
||||||
|
"socialize.workflow.stale_in_approval",
|
||||||
|
ObserveStaleApprovalCount,
|
||||||
|
description: "Current count of content items in approval longer than the configured stale threshold.");
|
||||||
|
Meter.CreateObservableGauge(
|
||||||
|
"socialize.workflow.active_workspaces",
|
||||||
|
ObserveActiveWorkspaceCounts,
|
||||||
|
description: "Current active workspace counts by observation window.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Meter Meter { get; }
|
||||||
|
|
||||||
|
public ActivitySource ActivitySource { get; }
|
||||||
|
|
||||||
|
public void RecordLoginAttempt(bool succeeded, string reason)
|
||||||
|
{
|
||||||
|
_loginAttemptCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"),
|
||||||
|
new KeyValuePair<string, object?>("reason", reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordOrganizationCreated(Guid organizationId)
|
||||||
|
{
|
||||||
|
_organizationCreatedCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("organization.id", organizationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordWorkspaceCreated(Guid organizationId, Guid workspaceId)
|
||||||
|
{
|
||||||
|
_workspaceCreatedCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("organization.id", organizationId),
|
||||||
|
new KeyValuePair<string, object?>("workspace.id", workspaceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordContentItemCreated(Guid workspaceId)
|
||||||
|
{
|
||||||
|
_contentItemCreatedCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("workspace.id", workspaceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordCommentCreated(Guid workspaceId, bool hasAttachment)
|
||||||
|
{
|
||||||
|
_commentCreatedCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("workspace.id", workspaceId),
|
||||||
|
new KeyValuePair<string, object?>("has_attachment", hasAttachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordApprovalDecisionSubmitted(Guid workspaceId, string decision)
|
||||||
|
{
|
||||||
|
_approvalDecisionCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("workspace.id", workspaceId),
|
||||||
|
new KeyValuePair<string, object?>("decision", decision));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordFeedbackSubmitted(string type, Guid? workspaceId)
|
||||||
|
{
|
||||||
|
_feedbackSubmittedCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("feedback.type", type),
|
||||||
|
new KeyValuePair<string, object?>("workspace.id", workspaceId?.ToString() ?? "none"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordWorkspaceInviteCreated(Guid workspaceId, string role)
|
||||||
|
{
|
||||||
|
_workspaceInviteCreatedCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("workspace.id", workspaceId),
|
||||||
|
new KeyValuePair<string, object?>("role", role));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordEmailDelivery(string provider, bool succeeded)
|
||||||
|
{
|
||||||
|
_emailDeliveryCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("provider", provider),
|
||||||
|
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordBlobStorageOperation(string operation, bool succeeded)
|
||||||
|
{
|
||||||
|
_blobStorageOperationCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("operation", operation),
|
||||||
|
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordBackgroundJobRun(string job, bool succeeded)
|
||||||
|
{
|
||||||
|
_backgroundJobRunCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("job", job),
|
||||||
|
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateWorkflowHealth(WorkflowHealthSnapshot snapshot)
|
||||||
|
{
|
||||||
|
lock (_workflowHealthLock)
|
||||||
|
{
|
||||||
|
_workflowHealthSnapshot = snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Meter.Dispose();
|
||||||
|
ActivitySource.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Measurement<int>[] ObserveContentItemCounts()
|
||||||
|
{
|
||||||
|
WorkflowHealthSnapshot snapshot = GetWorkflowHealthSnapshot();
|
||||||
|
return snapshot.ContentItemsByStatus
|
||||||
|
.Select(pair => new Measurement<int>(
|
||||||
|
pair.Value,
|
||||||
|
new KeyValuePair<string, object?>("status", pair.Key)))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Measurement<int>[] ObserveFeedbackReportCounts()
|
||||||
|
{
|
||||||
|
WorkflowHealthSnapshot snapshot = GetWorkflowHealthSnapshot();
|
||||||
|
return snapshot.FeedbackReportsByStatus
|
||||||
|
.Select(pair => new Measurement<int>(
|
||||||
|
pair.Value,
|
||||||
|
new KeyValuePair<string, object?>("status", pair.Key)))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Measurement<int> ObservePendingInviteCount()
|
||||||
|
{
|
||||||
|
return new Measurement<int>(GetWorkflowHealthSnapshot().PendingInviteCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Measurement<int> ObserveStaleApprovalCount()
|
||||||
|
{
|
||||||
|
return new Measurement<int>(GetWorkflowHealthSnapshot().StaleInApprovalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Measurement<int>[] ObserveActiveWorkspaceCounts()
|
||||||
|
{
|
||||||
|
WorkflowHealthSnapshot snapshot = GetWorkflowHealthSnapshot();
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new Measurement<int>(
|
||||||
|
snapshot.ActiveWorkspaces24Hours,
|
||||||
|
new KeyValuePair<string, object?>("window", "24h")),
|
||||||
|
new Measurement<int>(
|
||||||
|
snapshot.ActiveWorkspaces7Days,
|
||||||
|
new KeyValuePair<string, object?>("window", "7d")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkflowHealthSnapshot GetWorkflowHealthSnapshot()
|
||||||
|
{
|
||||||
|
lock (_workflowHealthLock)
|
||||||
|
{
|
||||||
|
return _workflowHealthSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record WorkflowHealthSnapshot(
|
||||||
|
IReadOnlyDictionary<string, int> ContentItemsByStatus,
|
||||||
|
IReadOnlyDictionary<string, int> FeedbackReportsByStatus,
|
||||||
|
int PendingInviteCount,
|
||||||
|
int StaleInApprovalCount,
|
||||||
|
int ActiveWorkspaces24Hours,
|
||||||
|
int ActiveWorkspaces7Days)
|
||||||
|
{
|
||||||
|
public static WorkflowHealthSnapshot Empty { get; } = new(
|
||||||
|
new Dictionary<string, int>(StringComparer.Ordinal),
|
||||||
|
new Dictionary<string, int>(StringComparer.Ordinal),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Modules.Feedback.Data;
|
||||||
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
|
internal sealed class WorkflowHealthSamplerService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
SocializeMetrics metrics,
|
||||||
|
ILogger<WorkflowHealthSamplerService> logger)
|
||||||
|
: BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan SampleInterval = TimeSpan.FromMinutes(5);
|
||||||
|
private static readonly TimeSpan StaleApprovalThreshold = TimeSpan.FromDays(3);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await SampleAsync(stoppingToken);
|
||||||
|
|
||||||
|
using PeriodicTimer timer = new(SampleInterval);
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await timer.WaitForNextTickAsync(stoppingToken);
|
||||||
|
await SampleAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Workflow health sampler stopped.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SampleAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using IServiceScope scope = scopeFactory.CreateScope();
|
||||||
|
AppDbContext dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
DateTimeOffset staleApprovalCutoff = now.Subtract(StaleApprovalThreshold);
|
||||||
|
DateTimeOffset active24HourCutoff = now.AddHours(-24);
|
||||||
|
DateTimeOffset active7DayCutoff = now.AddDays(-7);
|
||||||
|
|
||||||
|
Dictionary<string, int> contentItemsByStatus = await dbContext.ContentItems
|
||||||
|
.GroupBy(item => item.Status)
|
||||||
|
.Select(group => new { Status = group.Key, Count = group.Count() })
|
||||||
|
.ToDictionaryAsync(group => group.Status, group => group.Count, StringComparer.Ordinal, stoppingToken);
|
||||||
|
|
||||||
|
Dictionary<string, int> feedbackReportsByStatus = await dbContext.FeedbackReports
|
||||||
|
.GroupBy(report => report.Status)
|
||||||
|
.Select(group => new { Status = group.Key, Count = group.Count() })
|
||||||
|
.ToDictionaryAsync(
|
||||||
|
group => group.Status == FeedbackStatus.WontDo ? "WontDo" : group.Status.ToString(),
|
||||||
|
group => group.Count,
|
||||||
|
StringComparer.Ordinal,
|
||||||
|
stoppingToken);
|
||||||
|
|
||||||
|
int pendingInviteCount = await dbContext.WorkspaceInvites
|
||||||
|
.CountAsync(invite => invite.Status == WorkspaceInviteStatuses.Pending, stoppingToken);
|
||||||
|
|
||||||
|
int staleInApprovalCount = await dbContext.ContentItems
|
||||||
|
.CountAsync(
|
||||||
|
item => item.Status == "In approval" && item.CreatedAt <= staleApprovalCutoff,
|
||||||
|
stoppingToken);
|
||||||
|
|
||||||
|
int activeWorkspaces24Hours = await dbContext.ContentItemActivityEntries
|
||||||
|
.Where(entry => entry.CreatedAt >= active24HourCutoff)
|
||||||
|
.Select(entry => entry.WorkspaceId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(stoppingToken);
|
||||||
|
|
||||||
|
int activeWorkspaces7Days = await dbContext.ContentItemActivityEntries
|
||||||
|
.Where(entry => entry.CreatedAt >= active7DayCutoff)
|
||||||
|
.Select(entry => entry.WorkspaceId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(stoppingToken);
|
||||||
|
|
||||||
|
metrics.UpdateWorkflowHealth(new WorkflowHealthSnapshot(
|
||||||
|
contentItemsByStatus,
|
||||||
|
feedbackReportsByStatus,
|
||||||
|
pendingInviteCount,
|
||||||
|
staleInApprovalCount,
|
||||||
|
activeWorkspaces24Hours,
|
||||||
|
activeWorkspaces7Days));
|
||||||
|
metrics.RecordBackgroundJobRun(nameof(WorkflowHealthSamplerService), true);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Workflow health sampler stopped.");
|
||||||
|
}
|
||||||
|
#pragma warning disable CA1031
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
metrics.RecordBackgroundJobRun(nameof(WorkflowHealthSamplerService), false);
|
||||||
|
logger.LogError(ex, "Workflow health sampling failed.");
|
||||||
|
}
|
||||||
|
#pragma warning restore CA1031
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ internal sealed class AccessScopeService(
|
|||||||
|
|
||||||
public static bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId)
|
public static bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId)
|
||||||
{
|
{
|
||||||
return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId);
|
return user.GetWorkspaceScopeIds().Contains(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
|
public static bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
|
||||||
@@ -34,24 +34,25 @@ internal sealed class AccessScopeService(
|
|||||||
|
|
||||||
public static bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
|
public static bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
|
||||||
{
|
{
|
||||||
return IsManager(user)
|
return CanAccessWorkspace(user, workspaceId) &&
|
||||||
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId));
|
(IsManager(user) || user.GetClientScopeIds().Contains(clientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
public static bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
||||||
{
|
{
|
||||||
return IsManager(user)
|
return CanAccessClient(user, workspaceId, clientId) &&
|
||||||
|| (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId));
|
(IsManager(user) || user.GetCampaignScopeIds().Contains(campaignId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
public static bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
||||||
{
|
{
|
||||||
return IsManager(user) || (IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId));
|
return IsManager(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|
||||||
|
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
public static bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
|
||||||
{
|
{
|
||||||
return IsManager(user)
|
return IsManager(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|
||||||
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|
||||||
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
|
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ internal sealed class AccessScopeService(
|
|||||||
Guid workspaceId,
|
Guid workspaceId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
return CanAccessWorkspace(user, workspaceId)
|
return user.GetWorkspaceScopeIds().Contains(workspaceId)
|
||||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||||
user,
|
user,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -81,7 +82,7 @@ internal sealed class AccessScopeService(
|
|||||||
Guid workspaceId,
|
Guid workspaceId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
return IsManager(user)
|
return CanManageWorkspace(user, workspaceId)
|
||||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||||
user,
|
user,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -94,8 +95,7 @@ internal sealed class AccessScopeService(
|
|||||||
Guid organizationId,
|
Guid organizationId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
return IsManager(user)
|
return await organizationAccessService.HasOrganizationPermissionAsync(
|
||||||
|| await organizationAccessService.HasOrganizationPermissionAsync(
|
|
||||||
user,
|
user,
|
||||||
organizationId,
|
organizationId,
|
||||||
OrganizationPermissions.CreateWorkspaces,
|
OrganizationPermissions.CreateWorkspaces,
|
||||||
@@ -108,8 +108,7 @@ internal sealed class AccessScopeService(
|
|||||||
Guid clientId,
|
Guid clientId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (IsManager(user) ||
|
if (await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
|
||||||
user,
|
user,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||||
@@ -128,8 +127,7 @@ internal sealed class AccessScopeService(
|
|||||||
Guid campaignId,
|
Guid campaignId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (IsManager(user) ||
|
if (await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
|
||||||
user,
|
user,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
OrganizationPermissions.AccessOwnedWorkspaces,
|
OrganizationPermissions.AccessOwnedWorkspaces,
|
||||||
@@ -149,7 +147,7 @@ internal sealed class AccessScopeService(
|
|||||||
Guid campaignId,
|
Guid campaignId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
return IsManager(user)
|
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|
||||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||||
user,
|
user,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -165,7 +163,7 @@ internal sealed class AccessScopeService(
|
|||||||
Guid campaignId,
|
Guid campaignId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
return IsManager(user)
|
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|
||||||
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
|
||||||
user,
|
user,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
|||||||
2591
backend/src/Socialize.Api/Migrations/20260508030349_SimplifyReleaseUpdates.Designer.cs
generated
Normal file
2591
backend/src/Socialize.Api/Migrations/20260508030349_SimplifyReleaseUpdates.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
internal partial class SimplifyReleaseUpdates : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ReleaseUpdates_Audience",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Audience",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Body",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BuildVersion",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Category",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CommitRange",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DeploymentLabel",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Importance",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ManualEmailAudience",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Audience",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Body",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(8000)",
|
||||||
|
maxLength: 8000,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "BuildVersion",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Category",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CommitRange",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "DeploymentLabel",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Importance",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ManualEmailAudience",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ReleaseUpdates_Audience",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
column: "Audience");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2601
backend/src/Socialize.Api/Migrations/20260508031114_AddFrenchReleaseUpdateFields.Designer.cs
generated
Normal file
2601
backend/src/Socialize.Api/Migrations/20260508031114_AddFrenchReleaseUpdateFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
internal partial class AddFrenchReleaseUpdateFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SummaryFr",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TitleFr",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(160)",
|
||||||
|
maxLength: 160,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE "ReleaseUpdates"
|
||||||
|
SET "TitleFr" = "Title",
|
||||||
|
"SummaryFr" = "Summary"
|
||||||
|
WHERE "TitleFr" = '' AND "SummaryFr" = '';
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SummaryFr",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TitleFr",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2592
backend/src/Socialize.Api/Migrations/20260508034156_RemoveManualReleaseUpdateEmail.Designer.cs
generated
Normal file
2592
backend/src/Socialize.Api/Migrations/20260508034156_RemoveManualReleaseUpdateEmail.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
internal partial class RemoveManualReleaseUpdateEmail : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ManualEmailRecipientCount",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ManualEmailSentAt",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ManualEmailSentByUserId",
|
||||||
|
table: "ReleaseUpdates");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ManualEmailRecipientCount",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "ManualEmailSentAt",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ManualEmailSentByUserId",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2592
backend/src/Socialize.Api/Migrations/20260508034902_ExpandReleaseUpdateDescriptions.Designer.cs
generated
Normal file
2592
backend/src/Socialize.Api/Migrations/20260508034902_ExpandReleaseUpdateDescriptions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
internal partial class ExpandReleaseUpdateDescriptions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "SummaryFr",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(4000)",
|
||||||
|
maxLength: 4000,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(512)",
|
||||||
|
oldMaxLength: 512);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Summary",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(4000)",
|
||||||
|
maxLength: 4000,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(512)",
|
||||||
|
oldMaxLength: 512);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "SummaryFr",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(4000)",
|
||||||
|
oldMaxLength: 4000);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Summary",
|
||||||
|
table: "ReleaseUpdates",
|
||||||
|
type: "character varying(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(4000)",
|
||||||
|
oldMaxLength: 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2597
backend/src/Socialize.Api/Migrations/20260508122220_AddUserPreferredLanguage.Designer.cs
generated
Normal file
2597
backend/src/Socialize.Api/Migrations/20260508122220_AddUserPreferredLanguage.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
internal partial class AddUserPreferredLanguage : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PreferredLanguage",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "character varying(8)",
|
||||||
|
maxLength: 8,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PreferredLanguage",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1556,6 +1556,11 @@ namespace Socialize.Api.Migrations
|
|||||||
.HasMaxLength(2048)
|
.HasMaxLength(2048)
|
||||||
.HasColumnType("character varying(2048)");
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("PreferredLanguage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
b.Property<string>("RefreshToken")
|
b.Property<string>("RefreshToken")
|
||||||
.HasMaxLength(44)
|
.HasMaxLength(44)
|
||||||
.HasColumnType("character varying(44)");
|
.HasColumnType("character varying(44)");
|
||||||
@@ -1980,28 +1985,6 @@ namespace Socialize.Api.Migrations
|
|||||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("Audience")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
|
||||||
.HasColumnType("character varying(32)");
|
|
||||||
|
|
||||||
b.Property<string>("Body")
|
|
||||||
.HasMaxLength(8000)
|
|
||||||
.HasColumnType("character varying(8000)");
|
|
||||||
|
|
||||||
b.Property<string>("BuildVersion")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Category")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
|
||||||
.HasColumnType("character varying(32)");
|
|
||||||
|
|
||||||
b.Property<string>("CommitRange")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
@@ -2010,28 +1993,6 @@ namespace Socialize.Api.Migrations
|
|||||||
b.Property<Guid>("CreatedByUserId")
|
b.Property<Guid>("CreatedByUserId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("DeploymentLabel")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Importance")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
|
||||||
.HasColumnType("character varying(32)");
|
|
||||||
|
|
||||||
b.Property<string>("ManualEmailAudience")
|
|
||||||
.HasMaxLength(64)
|
|
||||||
.HasColumnType("character varying(64)");
|
|
||||||
|
|
||||||
b.Property<int?>("ManualEmailRecipientCount")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("ManualEmailSentAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<Guid?>("ManualEmailSentByUserId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("PublishedAt")
|
b.Property<DateTimeOffset?>("PublishedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -2042,21 +2003,29 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Summary")
|
b.Property<string>("Summary")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(512)
|
.HasMaxLength(4000)
|
||||||
.HasColumnType("character varying(512)");
|
.HasColumnType("character varying(4000)");
|
||||||
|
|
||||||
|
b.Property<string>("SummaryFr")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4000)
|
||||||
|
.HasColumnType("character varying(4000)");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(160)
|
.HasMaxLength(160)
|
||||||
.HasColumnType("character varying(160)");
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<string>("TitleFr")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("UpdatedAt")
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Audience");
|
|
||||||
|
|
||||||
b.HasIndex("CreatedByUserId");
|
b.HasIndex("CreatedByUserId");
|
||||||
|
|
||||||
b.HasIndex("PublishedAt");
|
b.HasIndex("PublishedAt");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.ContentItems.Data;
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
using Socialize.Api.Modules.ContentItems.Contracts;
|
using Socialize.Api.Modules.ContentItems.Contracts;
|
||||||
@@ -37,7 +38,8 @@ internal class SubmitApprovalDecisionHandler(
|
|||||||
AccessScopeService accessScopeService,
|
AccessScopeService accessScopeService,
|
||||||
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
|
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
|
||||||
IContentItemActivityWriter activityWriter,
|
IContentItemActivityWriter activityWriter,
|
||||||
INotificationEventWriter notificationEventWriter)
|
INotificationEventWriter notificationEventWriter,
|
||||||
|
SocializeMetrics metrics)
|
||||||
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -157,6 +159,7 @@ internal class SubmitApprovalDecisionHandler(
|
|||||||
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
metrics.RecordApprovalDecisionSubmitted(approval.WorkspaceId, normalizedDecision);
|
||||||
|
|
||||||
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
||||||
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
|
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||||
|
|
||||||
internal sealed class CalendarImportBackgroundService(
|
internal sealed class CalendarImportBackgroundService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
|
SocializeMetrics metrics,
|
||||||
ILogger<CalendarImportBackgroundService> logger)
|
ILogger<CalendarImportBackgroundService> logger)
|
||||||
: BackgroundService
|
: BackgroundService
|
||||||
{
|
{
|
||||||
@@ -22,6 +25,7 @@ internal sealed class CalendarImportBackgroundService(
|
|||||||
using IServiceScope scope = scopeFactory.CreateScope();
|
using IServiceScope scope = scopeFactory.CreateScope();
|
||||||
CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService<CalendarImportSyncService>();
|
CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService<CalendarImportSyncService>();
|
||||||
await syncService.RefreshDueSourcesAsync(stoppingToken);
|
await syncService.RefreshDueSourcesAsync(stoppingToken);
|
||||||
|
metrics.RecordBackgroundJobRun(nameof(CalendarImportBackgroundService), true);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -30,6 +34,7 @@ internal sealed class CalendarImportBackgroundService(
|
|||||||
#pragma warning disable CA1031 // Background service should log and continue after unexpected sync failures.
|
#pragma warning disable CA1031 // Background service should log and continue after unexpected sync failures.
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
metrics.RecordBackgroundJobRun(nameof(CalendarImportBackgroundService), false);
|
||||||
logger.LogError(ex, "Calendar import background sync failed.");
|
logger.LogError(ex, "Calendar import background sync failed.");
|
||||||
}
|
}
|
||||||
#pragma warning restore CA1031
|
#pragma warning restore CA1031
|
||||||
|
|||||||
@@ -34,23 +34,20 @@ internal class GetCampaignsHandler(
|
|||||||
{
|
{
|
||||||
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
|
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
|
||||||
|
|
||||||
if (!AccessScopeService.IsManager(User))
|
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
|
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||||
|
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
||||||
|
|
||||||
|
query = query.Where(campaign => workspaceScopeIds.Contains(campaign.WorkspaceId));
|
||||||
|
|
||||||
|
if (!AccessScopeService.IsManager(User) && clientScopeIds.Count > 0)
|
||||||
{
|
{
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
query = query.Where(campaign => clientScopeIds.Contains(campaign.ClientId));
|
||||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
}
|
||||||
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
|
||||||
|
|
||||||
query = query.Where(campaign => workspaceScopeIds.Contains(campaign.WorkspaceId));
|
if (!AccessScopeService.IsManager(User) && campaignScopeIds.Count > 0)
|
||||||
|
{
|
||||||
if (clientScopeIds.Count > 0)
|
query = query.Where(campaign => campaignScopeIds.Contains(campaign.Id));
|
||||||
{
|
|
||||||
query = query.Where(campaign => clientScopeIds.Contains(campaign.ClientId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (campaignScopeIds.Count > 0)
|
|
||||||
{
|
|
||||||
query = query.Where(campaign => campaignScopeIds.Contains(campaign.Id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.ClientId.HasValue)
|
if (request.ClientId.HasValue)
|
||||||
|
|||||||
@@ -23,11 +23,8 @@ internal class GetChannelsHandler(
|
|||||||
{
|
{
|
||||||
IQueryable<Channel> query = dbContext.Channels.AsQueryable();
|
IQueryable<Channel> query = dbContext.Channels.AsQueryable();
|
||||||
|
|
||||||
if (!AccessScopeService.IsManager(User))
|
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
{
|
query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId));
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
|
||||||
query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.WorkspaceId.HasValue)
|
if (request.WorkspaceId.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,18 +33,14 @@ internal class GetClientsHandler(
|
|||||||
{
|
{
|
||||||
IQueryable<Client> query = dbContext.Clients.AsQueryable();
|
IQueryable<Client> query = dbContext.Clients.AsQueryable();
|
||||||
|
|
||||||
if (!AccessScopeService.IsManager(User))
|
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
|
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||||
|
|
||||||
|
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
|
||||||
|
|
||||||
|
if (!AccessScopeService.IsManager(User) && clientScopeIds.Count > 0)
|
||||||
{
|
{
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
query = query.Where(client => clientScopeIds.Contains(client.Id));
|
||||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
|
||||||
|
|
||||||
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
|
|
||||||
|
|
||||||
if (clientScopeIds.Count > 0)
|
|
||||||
{
|
|
||||||
query = query.Where(client => clientScopeIds.Contains(client.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.WorkspaceId.HasValue)
|
if (request.WorkspaceId.HasValue)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FastEndpoints;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.ContentItems.Contracts;
|
using Socialize.Api.Modules.ContentItems.Contracts;
|
||||||
using Socialize.Api.Modules.ContentItems.Data;
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
@@ -34,7 +35,8 @@ internal class CreateCommentHandler(
|
|||||||
AccessScopeService accessScopeService,
|
AccessScopeService accessScopeService,
|
||||||
IBlobStorage blobStorage,
|
IBlobStorage blobStorage,
|
||||||
IContentItemActivityWriter activityWriter,
|
IContentItemActivityWriter activityWriter,
|
||||||
INotificationEventWriter notificationEventWriter)
|
INotificationEventWriter notificationEventWriter,
|
||||||
|
SocializeMetrics metrics)
|
||||||
: Endpoint<CreateCommentRequest, CommentDto>
|
: Endpoint<CreateCommentRequest, CommentDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -156,6 +158,7 @@ internal class CreateCommentHandler(
|
|||||||
|
|
||||||
dbContext.Comments.Add(comment);
|
dbContext.Comments.Add(comment);
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
metrics.RecordCommentCreated(comment.WorkspaceId, comment.AttachmentBlobName is not null);
|
||||||
|
|
||||||
string? authorPortraitUrl = await dbContext.Users
|
string? authorPortraitUrl = await dbContext.Users
|
||||||
.Where(candidate => candidate.Id == comment.AuthorUserId)
|
.Where(candidate => candidate.Id == comment.AuthorUserId)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.ContentItems.Contracts;
|
using Socialize.Api.Modules.ContentItems.Contracts;
|
||||||
using Socialize.Api.Modules.Notifications.Contracts;
|
using Socialize.Api.Modules.Notifications.Contracts;
|
||||||
@@ -39,7 +40,8 @@ internal class CreateContentItemHandler(
|
|||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
AccessScopeService accessScopeService,
|
AccessScopeService accessScopeService,
|
||||||
IContentItemActivityWriter activityWriter,
|
IContentItemActivityWriter activityWriter,
|
||||||
INotificationEventWriter notificationEventWriter)
|
INotificationEventWriter notificationEventWriter,
|
||||||
|
SocializeMetrics metrics)
|
||||||
: Endpoint<CreateContentItemRequest, ContentItemDto>
|
: Endpoint<CreateContentItemRequest, ContentItemDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -123,6 +125,7 @@ internal class CreateContentItemHandler(
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
});
|
});
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
metrics.RecordContentItemCreated(item.WorkspaceId);
|
||||||
|
|
||||||
await activityWriter.WriteAsync(
|
await activityWriter.WriteAsync(
|
||||||
new ContentItemActivityWriteModel(
|
new ContentItemActivityWriteModel(
|
||||||
|
|||||||
@@ -37,23 +37,20 @@ internal class GetContentItemsHandler(
|
|||||||
{
|
{
|
||||||
IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable();
|
IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable();
|
||||||
|
|
||||||
if (!AccessScopeService.IsManager(User))
|
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
|
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||||
|
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
||||||
|
|
||||||
|
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
|
||||||
|
|
||||||
|
if (!AccessScopeService.IsManager(User) && clientScopeIds.Count > 0)
|
||||||
{
|
{
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
|
||||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
}
|
||||||
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
|
||||||
|
|
||||||
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
|
if (!AccessScopeService.IsManager(User) && campaignScopeIds.Count > 0)
|
||||||
|
{
|
||||||
if (clientScopeIds.Count > 0)
|
query = query.Where(item => campaignScopeIds.Contains(item.CampaignId));
|
||||||
{
|
|
||||||
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (campaignScopeIds.Count > 0)
|
|
||||||
{
|
|
||||||
query = query.Where(item => campaignScopeIds.Contains(item.CampaignId));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.WorkspaceId.HasValue)
|
if (request.WorkspaceId.HasValue)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.Feedback.Contracts;
|
using Socialize.Api.Modules.Feedback.Contracts;
|
||||||
using Socialize.Api.Modules.Feedback.Data;
|
using Socialize.Api.Modules.Feedback.Data;
|
||||||
@@ -45,7 +46,8 @@ internal class SubmitFeedbackRequestValidator
|
|||||||
|
|
||||||
internal class SubmitFeedbackHandler(
|
internal class SubmitFeedbackHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
FeedbackNotificationService notificationService)
|
FeedbackNotificationService notificationService,
|
||||||
|
SocializeMetrics metrics)
|
||||||
: Endpoint<SubmitFeedbackRequest, FeedbackReportDto>
|
: Endpoint<SubmitFeedbackRequest, FeedbackReportDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -93,6 +95,7 @@ internal class SubmitFeedbackHandler(
|
|||||||
dbContext.FeedbackReports.Add(report);
|
dbContext.FeedbackReports.Add(report);
|
||||||
await notificationService.AddNewReportNotificationsAsync(report, ct);
|
await notificationService.AddNewReportNotificationsAsync(report, ct);
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
metrics.RecordFeedbackSubmitted(report.Type.ToString(), report.WorkspaceId);
|
||||||
|
|
||||||
await SendAsync(report.ToDto(), StatusCodes.Status201Created, ct);
|
await SendAsync(report.ToDto(), StatusCodes.Status201Created, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ internal class IdentityService(
|
|||||||
Firstname = user.Firstname,
|
Firstname = user.Firstname,
|
||||||
Lastname = user.Lastname,
|
Lastname = user.Lastname,
|
||||||
BirthDate = user.BirthDate,
|
BirthDate = user.BirthDate,
|
||||||
Address = user.Address
|
Address = user.Address,
|
||||||
|
PreferredLanguage = user.PreferredLanguage
|
||||||
};
|
};
|
||||||
|
|
||||||
ret = userModel;
|
ret = userModel;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ internal class User : IdentityUser<Guid>
|
|||||||
[MaxLength(2048)] public string? PortraitUrl { get; set; }
|
[MaxLength(2048)] public string? PortraitUrl { get; set; }
|
||||||
[MaxLength(256)] public string? GoogleId { get; set; }
|
[MaxLength(256)] public string? GoogleId { get; set; }
|
||||||
[MaxLength(256)] public string? FacebookId { get; set; }
|
[MaxLength(256)] public string? FacebookId { get; set; }
|
||||||
|
[MaxLength(8)] public string PreferredLanguage { get; set; } = "en";
|
||||||
[MaxLength(44)] public string? RefreshToken { get; set; }
|
[MaxLength(44)] public string? RefreshToken { get; set; }
|
||||||
public DateTime RefreshTokenExpiryTime { get; set; }
|
public DateTime RefreshTokenExpiryTime { get; set; }
|
||||||
public DateTimeOffset? LastAuthenticatedAt { get; set; }
|
public DateTimeOffset? LastAuthenticatedAt { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Identity.Data;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.Identity.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
internal record ChangePreferredLanguageRequest(string PreferredLanguage);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
internal class ChangePreferredLanguageValidator : Validator<ChangePreferredLanguageRequest>
|
||||||
|
{
|
||||||
|
public ChangePreferredLanguageValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PreferredLanguage)
|
||||||
|
.Must(value => value is "en" or "fr")
|
||||||
|
.WithMessage("Preferred language must be en or fr.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
internal class ChangePreferredLanguageHandler(UserManager userManager)
|
||||||
|
: Endpoint<ChangePreferredLanguageRequest>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/users/preferred-language");
|
||||||
|
Options(o => o.WithTags("Users"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(
|
||||||
|
ChangePreferredLanguageRequest request,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PreferredLanguage = request.PreferredLanguage;
|
||||||
|
|
||||||
|
IdentityResult result = await userManager.UpdateAsync(user);
|
||||||
|
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
await SendOkAsync(ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await SendUnauthorizedAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ internal class GetCurrentUserQueryHandler(
|
|||||||
Email = userModel.Email,
|
Email = userModel.Email,
|
||||||
BirthDate = userModel.BirthDate,
|
BirthDate = userModel.BirthDate,
|
||||||
Address = userModel.Address,
|
Address = userModel.Address,
|
||||||
|
PreferredLanguage = userModel.PreferredLanguage,
|
||||||
UserRoles = roles
|
UserRoles = roles
|
||||||
},
|
},
|
||||||
ct);
|
ct);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.Identity.Data;
|
using Socialize.Api.Modules.Identity.Data;
|
||||||
using Socialize.Api.Modules.Identity.Configuration;
|
using Socialize.Api.Modules.Identity.Configuration;
|
||||||
@@ -21,7 +22,8 @@ internal record LoginResponse(
|
|||||||
internal class LoginHandler(
|
internal class LoginHandler(
|
||||||
UserManager userManager,
|
UserManager userManager,
|
||||||
IOptionsSnapshot<JwtOptions> jwtOptions,
|
IOptionsSnapshot<JwtOptions> jwtOptions,
|
||||||
AccessTokenFactory accessTokenFactory)
|
AccessTokenFactory accessTokenFactory,
|
||||||
|
SocializeMetrics metrics)
|
||||||
: Endpoint<LoginRequest, LoginResponse>
|
: Endpoint<LoginRequest, LoginResponse>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -40,6 +42,7 @@ internal class LoginHandler(
|
|||||||
user ??= await userManager.FindByNameAsync(request.Email);
|
user ??= await userManager.FindByNameAsync(request.Email);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
|
metrics.RecordLoginAttempt(false, "unknown_user");
|
||||||
await SendStringAsync(
|
await SendStringAsync(
|
||||||
"Invalid email or password",
|
"Invalid email or password",
|
||||||
401,
|
401,
|
||||||
@@ -51,6 +54,7 @@ internal class LoginHandler(
|
|||||||
bool isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
|
bool isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
|
||||||
if (!isPasswordValid)
|
if (!isPasswordValid)
|
||||||
{
|
{
|
||||||
|
metrics.RecordLoginAttempt(false, "invalid_password");
|
||||||
await SendStringAsync(
|
await SendStringAsync(
|
||||||
"Invalid email or password",
|
"Invalid email or password",
|
||||||
401,
|
401,
|
||||||
@@ -61,6 +65,7 @@ internal class LoginHandler(
|
|||||||
// Check if the email is confirmed
|
// Check if the email is confirmed
|
||||||
if (!user.EmailConfirmed)
|
if (!user.EmailConfirmed)
|
||||||
{
|
{
|
||||||
|
metrics.RecordLoginAttempt(false, "email_unconfirmed");
|
||||||
await SendStringAsync(
|
await SendStringAsync(
|
||||||
"Email not verified. Please check your email for verification instructions.",
|
"Email not verified. Please check your email for verification instructions.",
|
||||||
401,
|
401,
|
||||||
@@ -76,6 +81,7 @@ internal class LoginHandler(
|
|||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
string accessToken = await accessTokenFactory.CreateAsync(user);
|
string accessToken = await accessTokenFactory.CreateAsync(user);
|
||||||
|
metrics.RecordLoginAttempt(true, "success");
|
||||||
|
|
||||||
await SendOkAsync(
|
await SendOkAsync(
|
||||||
new LoginResponse(accessToken, user.RefreshToken),
|
new LoginResponse(accessToken, user.RefreshToken),
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ internal class UserDto
|
|||||||
public string? PhoneNumber { get; init; }
|
public string? PhoneNumber { get; init; }
|
||||||
public DateTime? BirthDate { get; init; }
|
public DateTime? BirthDate { get; init; }
|
||||||
public string? Address { get; init; }
|
public string? Address { get; init; }
|
||||||
|
public string PreferredLanguage { get; init; } = "en";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ internal class UserModel
|
|||||||
public string? PhoneNumber { get; init; }
|
public string? PhoneNumber { get; init; }
|
||||||
public DateTime? BirthDate { get; init; }
|
public DateTime? BirthDate { get; init; }
|
||||||
public string? Address { get; init; }
|
public string? Address { get; init; }
|
||||||
|
public string PreferredLanguage { get; init; } = "en";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,13 +56,10 @@ internal class GetNotificationsHandler(
|
|||||||
IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable();
|
IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable();
|
||||||
Guid currentUserId = User.GetUserId();
|
Guid currentUserId = User.GetUserId();
|
||||||
|
|
||||||
if (!AccessScopeService.IsManager(User))
|
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
{
|
query = query.Where(notificationEvent =>
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
|
||||||
query = query.Where(notificationEvent =>
|
notificationEvent.RecipientUserId == currentUserId);
|
||||||
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
|
|
||||||
notificationEvent.RecipientUserId == currentUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
query = query.Where(notificationEvent =>
|
query = query.Where(notificationEvent =>
|
||||||
notificationEvent.RecipientUserId == null ||
|
notificationEvent.RecipientUserId == null ||
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.Organizations.Data;
|
using Socialize.Api.Modules.Organizations.Data;
|
||||||
using Socialize.Api.Modules.Organizations.Services;
|
using Socialize.Api.Modules.Organizations.Services;
|
||||||
@@ -21,7 +22,8 @@ internal class CreateOrganizationRequestValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal class CreateOrganizationHandler(
|
internal class CreateOrganizationHandler(
|
||||||
AppDbContext dbContext)
|
AppDbContext dbContext,
|
||||||
|
SocializeMetrics metrics)
|
||||||
: Endpoint<CreateOrganizationRequest, OrganizationDto>
|
: Endpoint<CreateOrganizationRequest, OrganizationDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -66,6 +68,7 @@ internal class CreateOrganizationHandler(
|
|||||||
dbContext.Organizations.Add(organization);
|
dbContext.Organizations.Add(organization);
|
||||||
dbContext.OrganizationMemberships.Add(ownerMembership);
|
dbContext.OrganizationMemberships.Add(ownerMembership);
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
metrics.RecordOrganizationCreated(organization.Id);
|
||||||
|
|
||||||
await SendAsync(
|
await SendAsync(
|
||||||
OrganizationDto.FromOrganization(
|
OrganizationDto.FromOrganization(
|
||||||
|
|||||||
@@ -2,29 +2,16 @@ using System.Security.Claims;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.Identity.Contracts;
|
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.Organizations.Services;
|
namespace Socialize.Api.Modules.Organizations.Services;
|
||||||
|
|
||||||
internal sealed class OrganizationAccessService(
|
internal sealed class OrganizationAccessService(
|
||||||
AppDbContext dbContext)
|
AppDbContext dbContext)
|
||||||
{
|
{
|
||||||
public static bool IsGlobalManager(ClaimsPrincipal user)
|
|
||||||
{
|
|
||||||
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyCollection<Guid>> GetAccessibleOrganizationIdsAsync(
|
public async Task<IReadOnlyCollection<Guid>> GetAccessibleOrganizationIdsAsync(
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (IsGlobalManager(user))
|
|
||||||
{
|
|
||||||
return await dbContext.Organizations
|
|
||||||
.Select(organization => organization.Id)
|
|
||||||
.ToArrayAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
Guid userId = user.GetUserId();
|
Guid userId = user.GetUserId();
|
||||||
|
|
||||||
Guid[] ownedOrganizationIds = await dbContext.Organizations
|
Guid[] ownedOrganizationIds = await dbContext.Organizations
|
||||||
@@ -47,13 +34,6 @@ internal sealed class OrganizationAccessService(
|
|||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (IsGlobalManager(user))
|
|
||||||
{
|
|
||||||
return await dbContext.Workspaces
|
|
||||||
.Select(workspace => workspace.Id)
|
|
||||||
.ToArrayAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
Guid[] directWorkspaceIds = user.GetWorkspaceScopeIds().ToArray();
|
Guid[] directWorkspaceIds = user.GetWorkspaceScopeIds().ToArray();
|
||||||
Guid[] organizationWorkspaceIds = await GetInheritedWorkspaceIdsAsync(user, OrganizationPermissions.AccessOwnedWorkspaces, ct);
|
Guid[] organizationWorkspaceIds = await GetInheritedWorkspaceIdsAsync(user, OrganizationPermissions.AccessOwnedWorkspaces, ct);
|
||||||
|
|
||||||
@@ -68,11 +48,6 @@ internal sealed class OrganizationAccessService(
|
|||||||
Guid organizationId,
|
Guid organizationId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (IsGlobalManager(user))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Guid userId = user.GetUserId();
|
Guid userId = user.GetUserId();
|
||||||
|
|
||||||
return await dbContext.Organizations.AnyAsync(
|
return await dbContext.Organizations.AnyAsync(
|
||||||
@@ -89,11 +64,6 @@ internal sealed class OrganizationAccessService(
|
|||||||
string permission,
|
string permission,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (IsGlobalManager(user))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Guid userId = user.GetUserId();
|
Guid userId = user.GetUserId();
|
||||||
|
|
||||||
bool owner = await dbContext.Organizations.AnyAsync(
|
bool owner = await dbContext.Organizations.AnyAsync(
|
||||||
@@ -117,11 +87,6 @@ internal sealed class OrganizationAccessService(
|
|||||||
Guid organizationId,
|
Guid organizationId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (IsGlobalManager(user))
|
|
||||||
{
|
|
||||||
return OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner);
|
|
||||||
}
|
|
||||||
|
|
||||||
Guid userId = user.GetUserId();
|
Guid userId = user.GetUserId();
|
||||||
|
|
||||||
bool owner = await dbContext.Organizations.AnyAsync(
|
bool owner = await dbContext.Organizations.AnyAsync(
|
||||||
@@ -150,11 +115,6 @@ internal sealed class OrganizationAccessService(
|
|||||||
string permission,
|
string permission,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (IsGlobalManager(user))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Guid? organizationId = await dbContext.Workspaces
|
Guid? organizationId = await dbContext.Workspaces
|
||||||
.Where(workspace => workspace.Id == workspaceId)
|
.Where(workspace => workspace.Id == workspaceId)
|
||||||
.Select(workspace => (Guid?)workspace.OrganizationId)
|
.Select(workspace => (Guid?)workspace.OrganizationId)
|
||||||
|
|||||||
@@ -5,23 +5,16 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
|||||||
internal record ReleaseUpdateDto(
|
internal record ReleaseUpdateDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Title,
|
string Title,
|
||||||
string Summary,
|
string Description,
|
||||||
string? Body,
|
string TitleEn,
|
||||||
string Category,
|
string DescriptionEn,
|
||||||
string Importance,
|
string TitleFr,
|
||||||
string Audience,
|
string DescriptionFr,
|
||||||
string Status,
|
string Status,
|
||||||
string? DeploymentLabel,
|
|
||||||
string? BuildVersion,
|
|
||||||
string? CommitRange,
|
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset UpdatedAt,
|
DateTimeOffset UpdatedAt,
|
||||||
DateTimeOffset? PublishedAt,
|
DateTimeOffset? PublishedAt,
|
||||||
DateTimeOffset? ArchivedAt,
|
DateTimeOffset? ArchivedAt,
|
||||||
Guid? ManualEmailSentByUserId,
|
|
||||||
DateTimeOffset? ManualEmailSentAt,
|
|
||||||
string? ManualEmailAudience,
|
|
||||||
int? ManualEmailRecipientCount,
|
|
||||||
bool IsRead);
|
bool IsRead);
|
||||||
|
|
||||||
internal record ReleaseCommitDto(
|
internal record ReleaseCommitDto(
|
||||||
@@ -40,22 +33,21 @@ internal record ReleaseCommitDto(
|
|||||||
DateTimeOffset ImportedAt,
|
DateTimeOffset ImportedAt,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
internal record ReleaseCommitImportResultDto(
|
internal record ReleaseCommitRefreshResultDto(
|
||||||
int ImportedCount,
|
int CreatedCount,
|
||||||
int UpdatedCount,
|
int UpdatedCount,
|
||||||
int SkippedCount,
|
int SkippedCount,
|
||||||
IReadOnlyCollection<ReleaseCommitDto> Commits);
|
IReadOnlyCollection<ReleaseCommitDto> Commits);
|
||||||
|
|
||||||
internal record ReleaseUpdateEmailSendResultDto(
|
internal record ReleaseCommitBulkLinkResultDto(int LinkedCount);
|
||||||
int RecipientCount,
|
|
||||||
DateTimeOffset SentAt,
|
|
||||||
bool TestMode);
|
|
||||||
|
|
||||||
internal record ReleaseUpdateUnreadSummaryDto(
|
internal record ReleaseUpdateUnreadSummaryDto(
|
||||||
int UnreadCount,
|
int UnreadCount,
|
||||||
int ImportantUnreadCount,
|
int ImportantUnreadCount,
|
||||||
IReadOnlyCollection<ReleaseUpdateDto> Updates);
|
IReadOnlyCollection<ReleaseUpdateDto> Updates);
|
||||||
|
|
||||||
|
internal record ReleaseUpdateDigestSendResultDto(int SentCount);
|
||||||
|
|
||||||
internal static class ReleaseUpdateDtoMapper
|
internal static class ReleaseUpdateDtoMapper
|
||||||
{
|
{
|
||||||
public static ReleaseUpdateDto ToDto(this ReleaseUpdate update, bool isRead)
|
public static ReleaseUpdateDto ToDto(this ReleaseUpdate update, bool isRead)
|
||||||
@@ -64,22 +56,15 @@ internal static class ReleaseUpdateDtoMapper
|
|||||||
update.Id,
|
update.Id,
|
||||||
update.Title,
|
update.Title,
|
||||||
update.Summary,
|
update.Summary,
|
||||||
update.Body,
|
update.Title,
|
||||||
ToDisplayString(update.Category),
|
update.Summary,
|
||||||
update.Importance.ToString(),
|
update.TitleFr,
|
||||||
update.Audience.ToString(),
|
update.SummaryFr,
|
||||||
update.Status.ToString(),
|
update.Status.ToString(),
|
||||||
update.DeploymentLabel,
|
|
||||||
update.BuildVersion,
|
|
||||||
update.CommitRange,
|
|
||||||
update.CreatedAt,
|
update.CreatedAt,
|
||||||
update.UpdatedAt,
|
update.UpdatedAt,
|
||||||
update.PublishedAt,
|
update.PublishedAt,
|
||||||
update.ArchivedAt,
|
update.ArchivedAt,
|
||||||
update.ManualEmailSentByUserId,
|
|
||||||
update.ManualEmailSentAt,
|
|
||||||
update.ManualEmailAudience,
|
|
||||||
update.ManualEmailRecipientCount,
|
|
||||||
isRead);
|
isRead);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,8 +87,4 @@ internal static class ReleaseUpdateDtoMapper
|
|||||||
commit.UpdatedAt);
|
commit.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ToDisplayString(ReleaseUpdateCategory category)
|
|
||||||
{
|
|
||||||
return category == ReleaseUpdateCategory.BreakingChange ? "Breaking Change" : category.ToString();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,19 +11,12 @@ internal static class ReleaseCommunicationsModelConfiguration
|
|||||||
releaseUpdate.ToTable("ReleaseUpdates");
|
releaseUpdate.ToTable("ReleaseUpdates");
|
||||||
releaseUpdate.HasKey(x => x.Id);
|
releaseUpdate.HasKey(x => x.Id);
|
||||||
releaseUpdate.Property(x => x.Title).HasMaxLength(160).IsRequired();
|
releaseUpdate.Property(x => x.Title).HasMaxLength(160).IsRequired();
|
||||||
releaseUpdate.Property(x => x.Summary).HasMaxLength(512).IsRequired();
|
releaseUpdate.Property(x => x.Summary).HasMaxLength(4000).IsRequired();
|
||||||
releaseUpdate.Property(x => x.Body).HasMaxLength(8000);
|
releaseUpdate.Property(x => x.TitleFr).HasMaxLength(160).IsRequired();
|
||||||
releaseUpdate.Property(x => x.Category).HasConversion<string>().HasMaxLength(32).IsRequired();
|
releaseUpdate.Property(x => x.SummaryFr).HasMaxLength(4000).IsRequired();
|
||||||
releaseUpdate.Property(x => x.Importance).HasConversion<string>().HasMaxLength(32).IsRequired();
|
|
||||||
releaseUpdate.Property(x => x.Audience).HasConversion<string>().HasMaxLength(32).IsRequired();
|
|
||||||
releaseUpdate.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
releaseUpdate.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||||
releaseUpdate.Property(x => x.DeploymentLabel).HasMaxLength(128);
|
|
||||||
releaseUpdate.Property(x => x.BuildVersion).HasMaxLength(128);
|
|
||||||
releaseUpdate.Property(x => x.CommitRange).HasMaxLength(256);
|
|
||||||
releaseUpdate.Property(x => x.ManualEmailAudience).HasMaxLength(64);
|
|
||||||
releaseUpdate.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
|
releaseUpdate.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
releaseUpdate.HasIndex(x => x.Status);
|
releaseUpdate.HasIndex(x => x.Status);
|
||||||
releaseUpdate.HasIndex(x => x.Audience);
|
|
||||||
releaseUpdate.HasIndex(x => x.PublishedAt);
|
releaseUpdate.HasIndex(x => x.PublishedAt);
|
||||||
releaseUpdate.HasIndex(x => x.CreatedByUserId);
|
releaseUpdate.HasIndex(x => x.CreatedByUserId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,22 +5,13 @@ internal class ReleaseUpdate
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public string Summary { get; set; } = string.Empty;
|
public string Summary { get; set; } = string.Empty;
|
||||||
public string? Body { get; set; }
|
public string TitleFr { get; set; } = string.Empty;
|
||||||
public ReleaseUpdateCategory Category { get; set; }
|
public string SummaryFr { get; set; } = string.Empty;
|
||||||
public ReleaseUpdateImportance Importance { get; set; }
|
|
||||||
public ReleaseUpdateAudience Audience { get; set; }
|
|
||||||
public ReleaseUpdateStatus Status { get; set; }
|
public ReleaseUpdateStatus Status { get; set; }
|
||||||
public string? DeploymentLabel { get; set; }
|
|
||||||
public string? BuildVersion { get; set; }
|
|
||||||
public string? CommitRange { get; set; }
|
|
||||||
public Guid CreatedByUserId { get; set; }
|
public Guid CreatedByUserId { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
public DateTimeOffset UpdatedAt { get; set; }
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
public DateTimeOffset? PublishedAt { get; set; }
|
public DateTimeOffset? PublishedAt { get; set; }
|
||||||
public DateTimeOffset? ArchivedAt { get; set; }
|
public DateTimeOffset? ArchivedAt { get; set; }
|
||||||
public Guid? ManualEmailSentByUserId { get; set; }
|
|
||||||
public DateTimeOffset? ManualEmailSentAt { get; set; }
|
|
||||||
public string? ManualEmailAudience { get; set; }
|
|
||||||
public int? ManualEmailRecipientCount { get; set; }
|
|
||||||
public ICollection<ReleaseUpdateReadReceipt> ReadReceipts { get; } = new List<ReleaseUpdateReadReceipt>();
|
public ICollection<ReleaseUpdateReadReceipt> ReadReceipts { get; } = new List<ReleaseUpdateReadReceipt>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
|
||||||
|
|
||||||
internal enum ReleaseUpdateAudience
|
|
||||||
{
|
|
||||||
Everyone,
|
|
||||||
OrganizationOwners,
|
|
||||||
Developers,
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
|
||||||
|
|
||||||
internal enum ReleaseUpdateCategory
|
|
||||||
{
|
|
||||||
Feature,
|
|
||||||
Improvement,
|
|
||||||
Fix,
|
|
||||||
BreakingChange,
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
|
||||||
|
|
||||||
internal enum ReleaseUpdateImportance
|
|
||||||
{
|
|
||||||
Normal,
|
|
||||||
Important,
|
|
||||||
}
|
|
||||||
@@ -4,35 +4,24 @@ using Socialize.Api.Infrastructure.Security;
|
|||||||
using Socialize.Api.Modules.Identity.Contracts;
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Services;
|
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
||||||
|
|
||||||
internal record CreateDeveloperReleaseUpdateRequest(
|
internal record CreateDeveloperReleaseUpdateRequest(
|
||||||
string Title,
|
string TitleEn,
|
||||||
string Summary,
|
string DescriptionEn,
|
||||||
string? Body,
|
string TitleFr,
|
||||||
string Category,
|
string DescriptionFr);
|
||||||
string Importance,
|
|
||||||
string Audience,
|
|
||||||
string? DeploymentLabel,
|
|
||||||
string? BuildVersion,
|
|
||||||
string? CommitRange);
|
|
||||||
|
|
||||||
internal class CreateDeveloperReleaseUpdateRequestValidator
|
internal class CreateDeveloperReleaseUpdateRequestValidator
|
||||||
: Validator<CreateDeveloperReleaseUpdateRequest>
|
: Validator<CreateDeveloperReleaseUpdateRequest>
|
||||||
{
|
{
|
||||||
public CreateDeveloperReleaseUpdateRequestValidator()
|
public CreateDeveloperReleaseUpdateRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(160);
|
RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(160);
|
||||||
RuleFor(x => x.Summary).NotEmpty().MaximumLength(512);
|
RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000);
|
||||||
RuleFor(x => x.Body).MaximumLength(8000);
|
RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160);
|
||||||
RuleFor(x => x.Category).NotEmpty().MaximumLength(32);
|
RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000);
|
||||||
RuleFor(x => x.Importance).NotEmpty().MaximumLength(32);
|
|
||||||
RuleFor(x => x.Audience).NotEmpty().MaximumLength(32);
|
|
||||||
RuleFor(x => x.DeploymentLabel).MaximumLength(128);
|
|
||||||
RuleFor(x => x.BuildVersion).MaximumLength(128);
|
|
||||||
RuleFor(x => x.CommitRange).MaximumLength(256);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,26 +37,15 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
|
|||||||
|
|
||||||
public override async Task HandleAsync(CreateDeveloperReleaseUpdateRequest request, CancellationToken ct)
|
public override async Task HandleAsync(CreateDeveloperReleaseUpdateRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!TryParseRequest(request, out ReleaseUpdateCategory category, out ReleaseUpdateImportance importance, out ReleaseUpdateAudience audience))
|
|
||||||
{
|
|
||||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
ReleaseUpdate update = new()
|
ReleaseUpdate update = new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Title = request.Title.Trim(),
|
Title = request.TitleEn.Trim(),
|
||||||
Summary = request.Summary.Trim(),
|
Summary = request.DescriptionEn.Trim(),
|
||||||
Body = NormalizeOptional(request.Body),
|
TitleFr = request.TitleFr.Trim(),
|
||||||
Category = category,
|
SummaryFr = request.DescriptionFr.Trim(),
|
||||||
Importance = importance,
|
|
||||||
Audience = audience,
|
|
||||||
Status = ReleaseUpdateStatus.Draft,
|
Status = ReleaseUpdateStatus.Draft,
|
||||||
DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
|
|
||||||
BuildVersion = NormalizeOptional(request.BuildVersion),
|
|
||||||
CommitRange = NormalizeOptional(request.CommitRange),
|
|
||||||
CreatedByUserId = User.GetUserId(),
|
CreatedByUserId = User.GetUserId(),
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
UpdatedAt = now,
|
UpdatedAt = now,
|
||||||
@@ -78,38 +56,4 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
|
|||||||
|
|
||||||
await SendAsync(update.ToDto(false), StatusCodes.Status201Created, ct);
|
await SendAsync(update.ToDto(false), StatusCodes.Status201Created, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryParseRequest(
|
|
||||||
CreateDeveloperReleaseUpdateRequest request,
|
|
||||||
out ReleaseUpdateCategory category,
|
|
||||||
out ReleaseUpdateImportance importance,
|
|
||||||
out ReleaseUpdateAudience audience)
|
|
||||||
{
|
|
||||||
bool isValid = true;
|
|
||||||
if (!ReleaseUpdateRules.TryParseCategory(request.Category, out category))
|
|
||||||
{
|
|
||||||
AddError(x => x.Category, "The selected release update category is not valid.");
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ReleaseUpdateRules.TryParseImportance(request.Importance, out importance))
|
|
||||||
{
|
|
||||||
AddError(x => x.Importance, "The selected release update importance is not valid.");
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ReleaseUpdateRules.TryParseAudience(request.Audience, out audience))
|
|
||||||
{
|
|
||||||
AddError(x => x.Audience, "The selected release update audience is not valid.");
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? NormalizeOptional(string? value)
|
|
||||||
{
|
|
||||||
string? normalized = value?.Trim();
|
|
||||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
||||||
|
using Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
||||||
|
|
||||||
|
internal class ForceDeveloperReleaseUpdateDigestEmailsHandler(ReleaseUpdateEmailService emailService)
|
||||||
|
: EndpointWithoutRequest<ReleaseUpdateDigestSendResultDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/developer/release-update-email-digests/force");
|
||||||
|
Roles(KnownRoles.Developer);
|
||||||
|
Options(o => o.WithTags("Release Communications"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
int sentCount = await emailService.SendDueDigestEmailsAsync(
|
||||||
|
TimeSpan.Zero,
|
||||||
|
TimeSpan.Zero,
|
||||||
|
force: true,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
await SendOkAsync(new ReleaseUpdateDigestSendResultDto(sentCount), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,9 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
|
|||||||
public override async Task HandleAsync(CancellationToken ct)
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
Guid userId = User.GetUserId();
|
Guid userId = User.GetUserId();
|
||||||
ReleaseUpdateAudienceContext audienceContext =
|
|
||||||
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
|
|
||||||
|
|
||||||
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
|
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
|
||||||
.VisibleTo(audienceContext)
|
.VisibleToUsers()
|
||||||
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
|
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
|
||||||
receipt.ReleaseUpdateId == update.Id &&
|
receipt.ReleaseUpdateId == update.Id &&
|
||||||
receipt.UserId == userId))
|
receipt.UserId == userId))
|
||||||
@@ -35,7 +33,7 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
|
|||||||
await SendOkAsync(
|
await SendOkAsync(
|
||||||
new ReleaseUpdateUnreadSummaryDto(
|
new ReleaseUpdateUnreadSummaryDto(
|
||||||
unreadUpdates.Count,
|
unreadUpdates.Count,
|
||||||
unreadUpdates.Count(update => update.Importance == ReleaseUpdateImportance.Important),
|
0,
|
||||||
unreadUpdates.Select(update => update.ToDto(false)).ToArray()),
|
unreadUpdates.Select(update => update.ToDto(false)).ToArray()),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
using FastEndpoints;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Socialize.Api.Data;
|
|
||||||
using Socialize.Api.Modules.Identity.Contracts;
|
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Services;
|
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
|
||||||
|
|
||||||
internal record ImportDeveloperReleaseCommitDto(
|
|
||||||
string Sha,
|
|
||||||
string? ShortSha,
|
|
||||||
string Subject,
|
|
||||||
string? AuthorName,
|
|
||||||
string? AuthorEmail,
|
|
||||||
DateTimeOffset? AuthoredAt,
|
|
||||||
DateTimeOffset? CommittedAt,
|
|
||||||
string? SourceBranch,
|
|
||||||
string? DeploymentLabel,
|
|
||||||
string? ExternalUrl);
|
|
||||||
|
|
||||||
internal record ImportDeveloperReleaseCommitsRequest(
|
|
||||||
string? SinceSha,
|
|
||||||
string? UntilSha,
|
|
||||||
string? SourceBranch,
|
|
||||||
string? DeploymentLabel,
|
|
||||||
DateTimeOffset? Since,
|
|
||||||
DateTimeOffset? Until,
|
|
||||||
int? Limit,
|
|
||||||
IReadOnlyCollection<ImportDeveloperReleaseCommitDto>? Commits);
|
|
||||||
|
|
||||||
internal class ImportDeveloperReleaseCommitsHandler(
|
|
||||||
AppDbContext dbContext,
|
|
||||||
ReleaseCommitRepositoryImportService repositoryImportService)
|
|
||||||
: Endpoint<ImportDeveloperReleaseCommitsRequest, ReleaseCommitImportResultDto>
|
|
||||||
{
|
|
||||||
public override void Configure()
|
|
||||||
{
|
|
||||||
Post("/api/developer/release-commits/import");
|
|
||||||
Roles(KnownRoles.Developer);
|
|
||||||
Options(o => o.WithTags("Release Communications"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task HandleAsync(ImportDeveloperReleaseCommitsRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
IReadOnlyCollection<ReleaseCommit> requestedCommits;
|
|
||||||
if (request.Commits is { Count: > 0 })
|
|
||||||
{
|
|
||||||
requestedCommits = request.Commits.Select(ToReleaseCommit).ToArray();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ReleaseCommitRepositoryImportResult importResult = await repositoryImportService.FetchCommitsAsync(request, ct);
|
|
||||||
if (!importResult.IsSuccess)
|
|
||||||
{
|
|
||||||
AddError(importResult.ErrorMessage ?? "Repository commit import failed.");
|
|
||||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestedCommits = importResult.Commits;
|
|
||||||
}
|
|
||||||
catch (HttpRequestException ex)
|
|
||||||
{
|
|
||||||
AddError(ex.Message);
|
|
||||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (JsonException ex)
|
|
||||||
{
|
|
||||||
AddError(ex.Message);
|
|
||||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int imported = 0;
|
|
||||||
int updated = 0;
|
|
||||||
int skipped = 0;
|
|
||||||
List<ReleaseCommit> savedCommits = [];
|
|
||||||
foreach (ReleaseCommit requestedCommit in requestedCommits)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(requestedCommit.Sha) || string.IsNullOrWhiteSpace(requestedCommit.Subject))
|
|
||||||
{
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ReleaseCommit? existingCommit = await dbContext.ReleaseCommits.SingleOrDefaultAsync(
|
|
||||||
commit => commit.Sha == requestedCommit.Sha,
|
|
||||||
ct);
|
|
||||||
|
|
||||||
if (existingCommit is null)
|
|
||||||
{
|
|
||||||
dbContext.ReleaseCommits.Add(requestedCommit);
|
|
||||||
savedCommits.Add(requestedCommit);
|
|
||||||
imported++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
existingCommit.ShortSha = requestedCommit.ShortSha;
|
|
||||||
existingCommit.Subject = requestedCommit.Subject;
|
|
||||||
existingCommit.AuthorName = requestedCommit.AuthorName;
|
|
||||||
existingCommit.AuthorEmail = requestedCommit.AuthorEmail;
|
|
||||||
existingCommit.AuthoredAt = requestedCommit.AuthoredAt;
|
|
||||||
existingCommit.CommittedAt = requestedCommit.CommittedAt;
|
|
||||||
existingCommit.SourceBranch = requestedCommit.SourceBranch ?? existingCommit.SourceBranch;
|
|
||||||
existingCommit.DeploymentLabel = requestedCommit.DeploymentLabel ?? existingCommit.DeploymentLabel;
|
|
||||||
existingCommit.ExternalUrl = requestedCommit.ExternalUrl ?? existingCommit.ExternalUrl;
|
|
||||||
existingCommit.UpdatedAt = DateTimeOffset.UtcNow;
|
|
||||||
savedCommits.Add(existingCommit);
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(ct);
|
|
||||||
await SendOkAsync(
|
|
||||||
new ReleaseCommitImportResultDto(imported, updated, skipped, savedCommits.Select(commit => commit.ToDto()).ToArray()),
|
|
||||||
ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ReleaseCommit ToReleaseCommit(ImportDeveloperReleaseCommitDto dto)
|
|
||||||
{
|
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
|
||||||
return new ReleaseCommit
|
|
||||||
{
|
|
||||||
Sha = dto.Sha.Trim(),
|
|
||||||
ShortSha = NormalizeOptional(dto.ShortSha) ?? dto.Sha.Trim()[..Math.Min(dto.Sha.Trim().Length, 12)],
|
|
||||||
Subject = dto.Subject.Trim(),
|
|
||||||
AuthorName = NormalizeOptional(dto.AuthorName),
|
|
||||||
AuthorEmail = NormalizeOptional(dto.AuthorEmail),
|
|
||||||
AuthoredAt = ToUtc(dto.AuthoredAt),
|
|
||||||
CommittedAt = ToUtc(dto.CommittedAt),
|
|
||||||
SourceBranch = NormalizeOptional(dto.SourceBranch),
|
|
||||||
DeploymentLabel = NormalizeOptional(dto.DeploymentLabel),
|
|
||||||
ExternalUrl = NormalizeOptional(dto.ExternalUrl),
|
|
||||||
CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed,
|
|
||||||
ImportedAt = now,
|
|
||||||
UpdatedAt = now,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? NormalizeOptional(string? value)
|
|
||||||
{
|
|
||||||
string? normalized = value?.Trim();
|
|
||||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DateTimeOffset? ToUtc(DateTimeOffset? value)
|
|
||||||
{
|
|
||||||
return value?.ToUniversalTime();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,11 +20,9 @@ internal class ListReleaseUpdatesHandler(AppDbContext dbContext)
|
|||||||
public override async Task HandleAsync(CancellationToken ct)
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
Guid userId = User.GetUserId();
|
Guid userId = User.GetUserId();
|
||||||
ReleaseUpdateAudienceContext audienceContext =
|
|
||||||
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
|
|
||||||
|
|
||||||
List<ReleaseUpdate> updates = await dbContext.ReleaseUpdates
|
List<ReleaseUpdate> updates = await dbContext.ReleaseUpdates
|
||||||
.VisibleTo(audienceContext)
|
.VisibleToUsers()
|
||||||
.OrderByDescending(update => update.PublishedAt)
|
.OrderByDescending(update => update.PublishedAt)
|
||||||
.ThenByDescending(update => update.CreatedAt)
|
.ThenByDescending(update => update.CreatedAt)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|||||||
@@ -19,11 +19,9 @@ internal class MarkAllReleaseUpdatesReadHandler(AppDbContext dbContext)
|
|||||||
public override async Task HandleAsync(CancellationToken ct)
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
Guid userId = User.GetUserId();
|
Guid userId = User.GetUserId();
|
||||||
ReleaseUpdateAudienceContext audienceContext =
|
|
||||||
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
|
|
||||||
|
|
||||||
List<Guid> visibleUpdateIds = await dbContext.ReleaseUpdates
|
List<Guid> visibleUpdateIds = await dbContext.ReleaseUpdates
|
||||||
.VisibleTo(audienceContext)
|
.VisibleToUsers()
|
||||||
.Select(update => update.Id)
|
.Select(update => update.Id)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,9 @@ internal class MarkReleaseUpdateReadHandler(AppDbContext dbContext)
|
|||||||
{
|
{
|
||||||
Guid id = Route<Guid>("id");
|
Guid id = Route<Guid>("id");
|
||||||
Guid userId = User.GetUserId();
|
Guid userId = User.GetUserId();
|
||||||
ReleaseUpdateAudienceContext audienceContext =
|
|
||||||
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
|
|
||||||
|
|
||||||
bool canReadUpdate = await dbContext.ReleaseUpdates
|
bool canReadUpdate = await dbContext.ReleaseUpdates
|
||||||
.VisibleTo(audienceContext)
|
.VisibleToUsers()
|
||||||
.AnyAsync(update => update.Id == id, ct);
|
.AnyAsync(update => update.Id == id, ct);
|
||||||
|
|
||||||
if (!canReadUpdate)
|
if (!canReadUpdate)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
||||||
|
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||||
|
using Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||||
|
|
||||||
|
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
||||||
|
|
||||||
|
internal class RefreshDeveloperReleaseCommitsHandler(
|
||||||
|
AppDbContext dbContext,
|
||||||
|
ReleaseCommitRepositoryRefreshService repositoryRefreshService)
|
||||||
|
: EndpointWithoutRequest<ReleaseCommitRefreshResultDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/developer/release-commits/refresh");
|
||||||
|
Roles(KnownRoles.Developer);
|
||||||
|
Options(o => o.WithTags("Release Communications"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyCollection<ReleaseCommit> requestedCommits;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ReleaseCommitRepositoryRefreshResult refreshResult = await repositoryRefreshService.FetchCommitsAsync(ct);
|
||||||
|
if (!refreshResult.IsSuccess)
|
||||||
|
{
|
||||||
|
AddError(refreshResult.ErrorMessage ?? "Repository commit refresh failed.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedCommits = refreshResult.Commits;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
AddError(ex.Message);
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
AddError(ex.Message);
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int created = 0;
|
||||||
|
int updated = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
List<ReleaseCommit> savedCommits = [];
|
||||||
|
foreach (ReleaseCommit requestedCommit in requestedCommits)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(requestedCommit.Sha) || string.IsNullOrWhiteSpace(requestedCommit.Subject))
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseCommit? existingCommit = await dbContext.ReleaseCommits.SingleOrDefaultAsync(
|
||||||
|
commit => commit.Sha == requestedCommit.Sha,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (existingCommit is null)
|
||||||
|
{
|
||||||
|
dbContext.ReleaseCommits.Add(requestedCommit);
|
||||||
|
savedCommits.Add(requestedCommit);
|
||||||
|
created++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingCommit.ShortSha = requestedCommit.ShortSha;
|
||||||
|
existingCommit.Subject = requestedCommit.Subject;
|
||||||
|
existingCommit.AuthorName = requestedCommit.AuthorName;
|
||||||
|
existingCommit.AuthorEmail = requestedCommit.AuthorEmail;
|
||||||
|
existingCommit.AuthoredAt = requestedCommit.AuthoredAt;
|
||||||
|
existingCommit.CommittedAt = requestedCommit.CommittedAt;
|
||||||
|
existingCommit.SourceBranch = requestedCommit.SourceBranch ?? existingCommit.SourceBranch;
|
||||||
|
existingCommit.DeploymentLabel = requestedCommit.DeploymentLabel ?? existingCommit.DeploymentLabel;
|
||||||
|
existingCommit.ExternalUrl = requestedCommit.ExternalUrl ?? existingCommit.ExternalUrl;
|
||||||
|
existingCommit.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
savedCommits.Add(existingCommit);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
await SendOkAsync(
|
||||||
|
new ReleaseCommitRefreshResultDto(created, updated, skipped, savedCommits.Select(commit => commit.ToDto()).ToArray()),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using FastEndpoints;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Socialize.Api.Data;
|
|
||||||
using Socialize.Api.Infrastructure.Security;
|
|
||||||
using Socialize.Api.Modules.Identity.Contracts;
|
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Services;
|
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
|
||||||
|
|
||||||
internal record SendDeveloperReleaseUpdateEmailRequest(
|
|
||||||
bool TestMode,
|
|
||||||
bool ConfirmResend);
|
|
||||||
|
|
||||||
internal class SendDeveloperReleaseUpdateEmailHandler(
|
|
||||||
AppDbContext dbContext,
|
|
||||||
ReleaseUpdateEmailService emailService)
|
|
||||||
: Endpoint<SendDeveloperReleaseUpdateEmailRequest, ReleaseUpdateEmailSendResultDto>
|
|
||||||
{
|
|
||||||
public override void Configure()
|
|
||||||
{
|
|
||||||
Post("/api/developer/release-updates/{id}/send-email");
|
|
||||||
Roles(KnownRoles.Developer);
|
|
||||||
Options(o => o.WithTags("Release Communications"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task HandleAsync(SendDeveloperReleaseUpdateEmailRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
Guid id = Route<Guid>("id");
|
|
||||||
ReleaseUpdate? update = await dbContext.ReleaseUpdates.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
|
||||||
if (update is null)
|
|
||||||
{
|
|
||||||
await SendNotFoundAsync(ct);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ReleaseUpdateEmailSendResultDto result = await emailService.SendManualUpdateEmailAsync(
|
|
||||||
update,
|
|
||||||
User.GetUserId(),
|
|
||||||
request.TestMode,
|
|
||||||
request.ConfirmResend,
|
|
||||||
ct);
|
|
||||||
await dbContext.SaveChangesAsync(ct);
|
|
||||||
await SendOkAsync(result, ct);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex)
|
|
||||||
{
|
|
||||||
AddError(ex.Message);
|
|
||||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,8 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
|||||||
|
|
||||||
internal record LinkDeveloperReleaseCommitRequest(Guid ReleaseUpdateId);
|
internal record LinkDeveloperReleaseCommitRequest(Guid ReleaseUpdateId);
|
||||||
|
|
||||||
|
internal record LinkFirstReleaseCommitsRequest(Guid ReleaseUpdateId);
|
||||||
|
|
||||||
internal abstract class ReleaseCommitStatusEndpoint(AppDbContext dbContext)
|
internal abstract class ReleaseCommitStatusEndpoint(AppDbContext dbContext)
|
||||||
: EndpointWithoutRequest<ReleaseCommitDto>
|
: EndpointWithoutRequest<ReleaseCommitDto>
|
||||||
{
|
{
|
||||||
@@ -67,6 +69,70 @@ internal class LinkDeveloperReleaseCommitHandler(AppDbContext dbContext)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class LinkFirstReleaseCommitsHandler(AppDbContext dbContext)
|
||||||
|
: Endpoint<LinkFirstReleaseCommitsRequest, ReleaseCommitBulkLinkResultDto>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/developer/release-commits/{sha}/link-first-release");
|
||||||
|
Roles(KnownRoles.Developer);
|
||||||
|
Options(o => o.WithTags("Release Communications"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(LinkFirstReleaseCommitsRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string? sha = Route<string>("sha");
|
||||||
|
if (string.IsNullOrWhiteSpace(sha))
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool releaseUpdateExists = await dbContext.ReleaseUpdates
|
||||||
|
.AnyAsync(update => update.Id == request.ReleaseUpdateId, ct);
|
||||||
|
ReleaseCommit? anchorCommit = await dbContext.ReleaseCommits
|
||||||
|
.SingleOrDefaultAsync(commit => commit.Sha == sha, ct);
|
||||||
|
|
||||||
|
if (!releaseUpdateExists || anchorCommit is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorCommit.ReleaseUpdateId is not null ||
|
||||||
|
anchorCommit.CommunicationStatus != ReleaseCommitCommunicationStatus.Unreviewed)
|
||||||
|
{
|
||||||
|
AddError("The selected first release commit must be unlinked and unreviewed.");
|
||||||
|
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset anchorDate = CommitDate(anchorCommit);
|
||||||
|
List<ReleaseCommit> commits = await dbContext.ReleaseCommits
|
||||||
|
.Where(commit =>
|
||||||
|
commit.ReleaseUpdateId == null &&
|
||||||
|
commit.CommunicationStatus == ReleaseCommitCommunicationStatus.Unreviewed &&
|
||||||
|
(commit.CommittedAt ?? commit.AuthoredAt ?? commit.ImportedAt) <= anchorDate)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
foreach (ReleaseCommit commit in commits)
|
||||||
|
{
|
||||||
|
commit.ReleaseUpdateId = request.ReleaseUpdateId;
|
||||||
|
commit.CommunicationStatus = ReleaseCommitCommunicationStatus.Linked;
|
||||||
|
commit.UpdatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
await SendOkAsync(new ReleaseCommitBulkLinkResultDto(commits.Count), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset CommitDate(ReleaseCommit commit)
|
||||||
|
{
|
||||||
|
return commit.CommittedAt ?? commit.AuthoredAt ?? commit.ImportedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal class UnlinkDeveloperReleaseCommitHandler(AppDbContext dbContext)
|
internal class UnlinkDeveloperReleaseCommitHandler(AppDbContext dbContext)
|
||||||
: ReleaseCommitStatusEndpoint(dbContext)
|
: ReleaseCommitStatusEndpoint(dbContext)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,35 +4,24 @@ using Socialize.Api.Data;
|
|||||||
using Socialize.Api.Modules.Identity.Contracts;
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Services;
|
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
||||||
|
|
||||||
internal record UpdateDeveloperReleaseUpdateRequest(
|
internal record UpdateDeveloperReleaseUpdateRequest(
|
||||||
string Title,
|
string TitleEn,
|
||||||
string Summary,
|
string DescriptionEn,
|
||||||
string? Body,
|
string TitleFr,
|
||||||
string Category,
|
string DescriptionFr);
|
||||||
string Importance,
|
|
||||||
string Audience,
|
|
||||||
string? DeploymentLabel,
|
|
||||||
string? BuildVersion,
|
|
||||||
string? CommitRange);
|
|
||||||
|
|
||||||
internal class UpdateDeveloperReleaseUpdateRequestValidator
|
internal class UpdateDeveloperReleaseUpdateRequestValidator
|
||||||
: Validator<UpdateDeveloperReleaseUpdateRequest>
|
: Validator<UpdateDeveloperReleaseUpdateRequest>
|
||||||
{
|
{
|
||||||
public UpdateDeveloperReleaseUpdateRequestValidator()
|
public UpdateDeveloperReleaseUpdateRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(160);
|
RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(160);
|
||||||
RuleFor(x => x.Summary).NotEmpty().MaximumLength(512);
|
RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000);
|
||||||
RuleFor(x => x.Body).MaximumLength(8000);
|
RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160);
|
||||||
RuleFor(x => x.Category).NotEmpty().MaximumLength(32);
|
RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000);
|
||||||
RuleFor(x => x.Importance).NotEmpty().MaximumLength(32);
|
|
||||||
RuleFor(x => x.Audience).NotEmpty().MaximumLength(32);
|
|
||||||
RuleFor(x => x.DeploymentLabel).MaximumLength(128);
|
|
||||||
RuleFor(x => x.BuildVersion).MaximumLength(128);
|
|
||||||
RuleFor(x => x.CommitRange).MaximumLength(256);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,58 +52,13 @@ internal class UpdateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryParseRequest(request, out ReleaseUpdateCategory category, out ReleaseUpdateImportance importance, out ReleaseUpdateAudience audience))
|
update.Title = request.TitleEn.Trim();
|
||||||
{
|
update.Summary = request.DescriptionEn.Trim();
|
||||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
update.TitleFr = request.TitleFr.Trim();
|
||||||
return;
|
update.SummaryFr = request.DescriptionFr.Trim();
|
||||||
}
|
|
||||||
|
|
||||||
update.Title = request.Title.Trim();
|
|
||||||
update.Summary = request.Summary.Trim();
|
|
||||||
update.Body = NormalizeOptional(request.Body);
|
|
||||||
update.Category = category;
|
|
||||||
update.Importance = importance;
|
|
||||||
update.Audience = audience;
|
|
||||||
update.DeploymentLabel = NormalizeOptional(request.DeploymentLabel);
|
|
||||||
update.BuildVersion = NormalizeOptional(request.BuildVersion);
|
|
||||||
update.CommitRange = NormalizeOptional(request.CommitRange);
|
|
||||||
update.UpdatedAt = DateTimeOffset.UtcNow;
|
update.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
await SendOkAsync(update.ToDto(false), ct);
|
await SendOkAsync(update.ToDto(false), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryParseRequest(
|
|
||||||
UpdateDeveloperReleaseUpdateRequest request,
|
|
||||||
out ReleaseUpdateCategory category,
|
|
||||||
out ReleaseUpdateImportance importance,
|
|
||||||
out ReleaseUpdateAudience audience)
|
|
||||||
{
|
|
||||||
bool isValid = true;
|
|
||||||
if (!ReleaseUpdateRules.TryParseCategory(request.Category, out category))
|
|
||||||
{
|
|
||||||
AddError(x => x.Category, "The selected release update category is not valid.");
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ReleaseUpdateRules.TryParseImportance(request.Importance, out importance))
|
|
||||||
{
|
|
||||||
AddError(x => x.Importance, "The selected release update importance is not valid.");
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ReleaseUpdateRules.TryParseAudience(request.Audience, out audience))
|
|
||||||
{
|
|
||||||
AddError(x => x.Audience, "The selected release update audience is not valid.");
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? NormalizeOptional(string? value)
|
|
||||||
{
|
|
||||||
string? normalized = value?.Trim();
|
|
||||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ internal static class ModuleRegistration
|
|||||||
builder.Services.Configure<ReleaseCommunicationRepositoryOptions>(
|
builder.Services.Configure<ReleaseCommunicationRepositoryOptions>(
|
||||||
builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName));
|
builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName));
|
||||||
builder.Services.AddScoped<ReleaseUpdateEmailService>();
|
builder.Services.AddScoped<ReleaseUpdateEmailService>();
|
||||||
builder.Services.AddScoped<ReleaseCommitRepositoryImportService>();
|
builder.Services.AddScoped<ReleaseCommitRepositoryRefreshService>();
|
||||||
builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>();
|
builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>();
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
|
|||||||
@@ -5,46 +5,41 @@ using System.Text.Json;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
|
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Handlers;
|
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||||
|
|
||||||
internal sealed record ReleaseCommitRepositoryImportResult(
|
internal sealed record ReleaseCommitRepositoryRefreshResult(
|
||||||
IReadOnlyCollection<ReleaseCommit> Commits,
|
IReadOnlyCollection<ReleaseCommit> Commits,
|
||||||
string? ErrorMessage)
|
string? ErrorMessage)
|
||||||
{
|
{
|
||||||
public bool IsSuccess => ErrorMessage is null;
|
public bool IsSuccess => ErrorMessage is null;
|
||||||
|
|
||||||
public static ReleaseCommitRepositoryImportResult Success(IReadOnlyCollection<ReleaseCommit> commits)
|
public static ReleaseCommitRepositoryRefreshResult Success(IReadOnlyCollection<ReleaseCommit> commits)
|
||||||
{
|
{
|
||||||
return new ReleaseCommitRepositoryImportResult(commits, null);
|
return new ReleaseCommitRepositoryRefreshResult(commits, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ReleaseCommitRepositoryImportResult Failure(string errorMessage)
|
public static ReleaseCommitRepositoryRefreshResult Failure(string errorMessage)
|
||||||
{
|
{
|
||||||
return new ReleaseCommitRepositoryImportResult([], errorMessage);
|
return new ReleaseCommitRepositoryRefreshResult([], errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class ReleaseCommitRepositoryImportService(
|
internal sealed class ReleaseCommitRepositoryRefreshService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptionsSnapshot<ReleaseCommunicationRepositoryOptions> repositoryOptions)
|
IOptionsSnapshot<ReleaseCommunicationRepositoryOptions> repositoryOptions)
|
||||||
{
|
{
|
||||||
private const int DefaultLimit = 50;
|
private const int DefaultLimit = 50;
|
||||||
private const int MaxLimit = 100;
|
|
||||||
|
|
||||||
public async Task<ReleaseCommitRepositoryImportResult> FetchCommitsAsync(
|
public async Task<ReleaseCommitRepositoryRefreshResult> FetchCommitsAsync(
|
||||||
ImportDeveloperReleaseCommitsRequest request,
|
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
ReleaseCommunicationRepositoryOptions options = repositoryOptions.Value;
|
ReleaseCommunicationRepositoryOptions options = repositoryOptions.Value;
|
||||||
if (!TryBuildApiTarget(options.RepositoryUrl, out RepositoryApiTarget target, out string? targetError))
|
if (!TryBuildApiTarget(options.RepositoryUrl, out RepositoryApiTarget target, out string? targetError))
|
||||||
{
|
{
|
||||||
return ReleaseCommitRepositoryImportResult.Failure(targetError ?? "Repository configuration is not valid.");
|
return ReleaseCommitRepositoryRefreshResult.Failure(targetError ?? "Repository configuration is not valid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
int limit = Math.Clamp(request.Limit ?? DefaultLimit, 1, MaxLimit);
|
|
||||||
|
|
||||||
using HttpClient httpClient = httpClientFactory.CreateClient();
|
using HttpClient httpClient = httpClientFactory.CreateClient();
|
||||||
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Socialize", "1.0"));
|
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Socialize", "1.0"));
|
||||||
if (!string.IsNullOrWhiteSpace(options.AccessToken))
|
if (!string.IsNullOrWhiteSpace(options.AccessToken))
|
||||||
@@ -52,11 +47,11 @@ internal sealed class ReleaseCommitRepositoryImportService(
|
|||||||
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", options.AccessToken.Trim());
|
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", options.AccessToken.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
using HttpResponseMessage response = await httpClient.GetAsync(BuildRequestUri(target, request, limit), ct);
|
using HttpResponseMessage response = await httpClient.GetAsync(BuildRequestUri(target), ct);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
return ReleaseCommitRepositoryImportResult.Failure(
|
return ReleaseCommitRepositoryRefreshResult.Failure(
|
||||||
$"Repository commit import failed with HTTP {(int)response.StatusCode} ({response.ReasonPhrase}).");
|
$"Repository commit refresh failed with HTTP {(int)response.StatusCode} ({response.ReasonPhrase}).");
|
||||||
}
|
}
|
||||||
|
|
||||||
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||||||
@@ -73,56 +68,31 @@ internal sealed class ReleaseCommitRepositoryImportService(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return ReleaseCommitRepositoryImportResult.Failure("Repository API response did not include a commit list.");
|
return ReleaseCommitRepositoryRefreshResult.Failure("Repository API response did not include a commit list.");
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
List<ReleaseCommit> commits = [];
|
List<ReleaseCommit> commits = [];
|
||||||
foreach (JsonElement commitElement in commitsElement.EnumerateArray())
|
foreach (JsonElement commitElement in commitsElement.EnumerateArray())
|
||||||
{
|
{
|
||||||
ReleaseCommit? commit = ToReleaseCommit(commitElement, request, now);
|
ReleaseCommit? commit = ToReleaseCommit(commitElement, now);
|
||||||
if (commit is not null)
|
if (commit is not null)
|
||||||
{
|
{
|
||||||
commits.Add(commit);
|
commits.Add(commit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ReleaseCommitRepositoryImportResult.Success(commits);
|
return ReleaseCommitRepositoryRefreshResult.Success(commits);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Uri BuildRequestUri(
|
private static Uri BuildRequestUri(RepositoryApiTarget target)
|
||||||
RepositoryApiTarget target,
|
|
||||||
ImportDeveloperReleaseCommitsRequest request,
|
|
||||||
int limit)
|
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(request.SinceSha) && !string.IsNullOrWhiteSpace(request.UntilSha))
|
|
||||||
{
|
|
||||||
string baseHead = $"{request.SinceSha.Trim()}...{request.UntilSha.Trim()}";
|
|
||||||
return new Uri($"{target.ApiBaseUri}/compare/{Uri.EscapeDataString(baseHead)}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<string, string> query = new(StringComparer.Ordinal)
|
Dictionary<string, string> query = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
["limit"] = limit.ToString(CultureInfo.InvariantCulture),
|
["limit"] = DefaultLimit.ToString(CultureInfo.InvariantCulture),
|
||||||
["page"] = "1",
|
["page"] = "1",
|
||||||
};
|
};
|
||||||
|
|
||||||
string? sha = NormalizeOptional(request.UntilSha) ?? NormalizeOptional(request.SourceBranch);
|
|
||||||
if (sha is not null)
|
|
||||||
{
|
|
||||||
query["sha"] = sha;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Since.HasValue)
|
|
||||||
{
|
|
||||||
query["since"] = request.Since.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Until.HasValue)
|
|
||||||
{
|
|
||||||
query["until"] = request.Until.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
string queryString = string.Join(
|
string queryString = string.Join(
|
||||||
"&",
|
"&",
|
||||||
query.Select(pair => $"{WebUtility.UrlEncode(pair.Key)}={WebUtility.UrlEncode(pair.Value)}"));
|
query.Select(pair => $"{WebUtility.UrlEncode(pair.Key)}={WebUtility.UrlEncode(pair.Value)}"));
|
||||||
@@ -140,7 +110,7 @@ internal sealed class ReleaseCommitRepositoryImportService(
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(repositoryUrl))
|
if (string.IsNullOrWhiteSpace(repositoryUrl))
|
||||||
{
|
{
|
||||||
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository import can be used.";
|
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository refresh can be used.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +146,6 @@ internal sealed class ReleaseCommitRepositoryImportService(
|
|||||||
|
|
||||||
private static ReleaseCommit? ToReleaseCommit(
|
private static ReleaseCommit? ToReleaseCommit(
|
||||||
JsonElement commitElement,
|
JsonElement commitElement,
|
||||||
ImportDeveloperReleaseCommitsRequest request,
|
|
||||||
DateTimeOffset now)
|
DateTimeOffset now)
|
||||||
{
|
{
|
||||||
string? sha = GetString(commitElement, "sha") ?? GetString(commitElement, "id");
|
string? sha = GetString(commitElement, "sha") ?? GetString(commitElement, "id");
|
||||||
@@ -211,8 +180,8 @@ internal sealed class ReleaseCommitRepositoryImportService(
|
|||||||
AuthorEmail = authorElement.HasValue ? NormalizeOptional(GetString(authorElement.Value, "email")) : null,
|
AuthorEmail = authorElement.HasValue ? NormalizeOptional(GetString(authorElement.Value, "email")) : null,
|
||||||
AuthoredAt = authorElement.HasValue ? GetUtcDateTimeOffset(authorElement.Value, "date") : null,
|
AuthoredAt = authorElement.HasValue ? GetUtcDateTimeOffset(authorElement.Value, "date") : null,
|
||||||
CommittedAt = committerElement.HasValue ? GetUtcDateTimeOffset(committerElement.Value, "date") : null,
|
CommittedAt = committerElement.HasValue ? GetUtcDateTimeOffset(committerElement.Value, "date") : null,
|
||||||
SourceBranch = NormalizeOptional(request.SourceBranch),
|
SourceBranch = null,
|
||||||
DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
|
DeploymentLabel = null,
|
||||||
ExternalUrl = NormalizeOptional(GetString(commitElement, "html_url") ?? GetString(commitElement, "url")),
|
ExternalUrl = NormalizeOptional(GetString(commitElement, "html_url") ?? GetString(commitElement, "url")),
|
||||||
CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed,
|
CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed,
|
||||||
ImportedAt = now,
|
ImportedAt = now,
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
|
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||||
@@ -6,6 +7,7 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
|||||||
internal sealed class ReleaseUpdateEmailDigestBackgroundService(
|
internal sealed class ReleaseUpdateEmailDigestBackgroundService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IOptions<ReleaseCommunicationEmailOptions> options,
|
IOptions<ReleaseCommunicationEmailOptions> options,
|
||||||
|
SocializeMetrics metrics,
|
||||||
ILogger<ReleaseUpdateEmailDigestBackgroundService> logger)
|
ILogger<ReleaseUpdateEmailDigestBackgroundService> logger)
|
||||||
: BackgroundService
|
: BackgroundService
|
||||||
{
|
{
|
||||||
@@ -40,7 +42,9 @@ internal sealed class ReleaseUpdateEmailDigestBackgroundService(
|
|||||||
int sentCount = await emailService.SendDueDigestEmailsAsync(
|
int sentCount = await emailService.SendDueDigestEmailsAsync(
|
||||||
TimeSpan.FromHours(options.Value.InactiveHoursBeforeDigest),
|
TimeSpan.FromHours(options.Value.InactiveHoursBeforeDigest),
|
||||||
TimeSpan.FromHours(options.Value.DigestIntervalHours),
|
TimeSpan.FromHours(options.Value.DigestIntervalHours),
|
||||||
stoppingToken);
|
force: false,
|
||||||
|
ct: stoppingToken);
|
||||||
|
metrics.RecordBackgroundJobRun(nameof(ReleaseUpdateEmailDigestBackgroundService), true);
|
||||||
if (sentCount > 0 && logger.IsEnabled(LogLevel.Information))
|
if (sentCount > 0 && logger.IsEnabled(LogLevel.Information))
|
||||||
{
|
{
|
||||||
logger.LogInformation("Sent {SentCount} release update digest emails.", sentCount);
|
logger.LogInformation("Sent {SentCount} release update digest emails.", sentCount);
|
||||||
@@ -53,6 +57,7 @@ internal sealed class ReleaseUpdateEmailDigestBackgroundService(
|
|||||||
#pragma warning disable CA1031
|
#pragma warning disable CA1031
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
metrics.RecordBackgroundJobRun(nameof(ReleaseUpdateEmailDigestBackgroundService), false);
|
||||||
logger.LogError(ex, "Release update digest service failed.");
|
logger.LogError(ex, "Release update digest service failed.");
|
||||||
}
|
}
|
||||||
#pragma warning restore CA1031
|
#pragma warning restore CA1031
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Infrastructure.Configuration;
|
using Socialize.Api.Infrastructure.Configuration;
|
||||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||||
using Socialize.Api.Modules.Identity.Contracts;
|
|
||||||
using Socialize.Api.Modules.Identity.Data;
|
using Socialize.Api.Modules.Identity.Data;
|
||||||
using Socialize.Api.Modules.Organizations.Services;
|
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
|
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||||
@@ -20,63 +16,22 @@ internal class ReleaseUpdateEmailService(
|
|||||||
IEmailSender emailSender,
|
IEmailSender emailSender,
|
||||||
IOptionsSnapshot<WebsiteOptions> websiteOptions)
|
IOptionsSnapshot<WebsiteOptions> websiteOptions)
|
||||||
{
|
{
|
||||||
public async Task<ReleaseUpdateEmailSendResultDto> SendManualUpdateEmailAsync(
|
|
||||||
ReleaseUpdate update,
|
|
||||||
Guid senderUserId,
|
|
||||||
bool testMode,
|
|
||||||
bool confirmResend,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (update.Status != ReleaseUpdateStatus.Published)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Only published release updates can be emailed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!testMode && update.ManualEmailSentAt.HasValue && !confirmResend)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("This release update was already emailed. Confirm resend to send it again.");
|
|
||||||
}
|
|
||||||
|
|
||||||
IReadOnlyCollection<User> recipients = testMode
|
|
||||||
? await GetTestRecipientsAsync(senderUserId, ct)
|
|
||||||
: await GetAudienceRecipientsAsync(update.Audience, ct);
|
|
||||||
|
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
|
||||||
foreach (User recipient in recipients.Where(recipient => !string.IsNullOrWhiteSpace(recipient.Email)))
|
|
||||||
{
|
|
||||||
await emailSender.SendEmailAsync(
|
|
||||||
recipient.Email!,
|
|
||||||
$"What's new in Socialize: {update.Title}",
|
|
||||||
BuildSingleUpdateEmail(update));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!testMode)
|
|
||||||
{
|
|
||||||
update.ManualEmailSentByUserId = senderUserId;
|
|
||||||
update.ManualEmailSentAt = now;
|
|
||||||
update.ManualEmailAudience = update.Audience.ToString();
|
|
||||||
update.ManualEmailRecipientCount = recipients.Count;
|
|
||||||
update.UpdatedAt = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ReleaseUpdateEmailSendResultDto(recipients.Count, now, testMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> SendDueDigestEmailsAsync(
|
public async Task<int> SendDueDigestEmailsAsync(
|
||||||
TimeSpan inactiveThreshold,
|
TimeSpan inactiveThreshold,
|
||||||
TimeSpan sendInterval,
|
TimeSpan sendInterval,
|
||||||
|
bool force,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold);
|
DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold);
|
||||||
DateTimeOffset lastSentBefore = now.Subtract(sendInterval);
|
DateTimeOffset lastSentBefore = now.Subtract(sendInterval);
|
||||||
|
|
||||||
List<User> ownerUsers = await GetAudienceRecipientsAsync(ReleaseUpdateAudience.OrganizationOwners, ct);
|
List<User> ownerUsers = await GetReleaseNoteRecipientsAsync(ct);
|
||||||
int sentCount = 0;
|
int sentCount = 0;
|
||||||
foreach (User user in ownerUsers)
|
foreach (User user in ownerUsers)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(user.Email) ||
|
if (string.IsNullOrWhiteSpace(user.Email) ||
|
||||||
!ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore))
|
(!force && !ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore)))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -86,19 +41,13 @@ internal class ReleaseUpdateEmailService(
|
|||||||
.OrderByDescending(receipt => receipt.SentAt)
|
.OrderByDescending(receipt => receipt.SentAt)
|
||||||
.Select(receipt => (DateTimeOffset?)receipt.SentAt)
|
.Select(receipt => (DateTimeOffset?)receipt.SentAt)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
if (!ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore))
|
if (!force && !ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ReleaseUpdateAudienceContext audienceContext = await ReleaseUpdateVisibility.GetAudienceContextAsync(
|
|
||||||
dbContext,
|
|
||||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
|
||||||
user.Id,
|
|
||||||
ct);
|
|
||||||
|
|
||||||
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
|
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
|
||||||
.VisibleTo(audienceContext)
|
.VisibleToUsers()
|
||||||
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
|
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
|
||||||
receipt.ReleaseUpdateId == update.Id &&
|
receipt.ReleaseUpdateId == update.Id &&
|
||||||
receipt.UserId == user.Id))
|
receipt.UserId == user.Id))
|
||||||
@@ -113,8 +62,8 @@ internal class ReleaseUpdateEmailService(
|
|||||||
|
|
||||||
await emailSender.SendEmailAsync(
|
await emailSender.SendEmailAsync(
|
||||||
user.Email,
|
user.Email,
|
||||||
"What's new in Socialize",
|
GetDigestSubject(user.PreferredLanguage),
|
||||||
BuildDigestEmail(unreadUpdates));
|
BuildDigestEmail(unreadUpdates, user.PreferredLanguage));
|
||||||
|
|
||||||
dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt
|
dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt
|
||||||
{
|
{
|
||||||
@@ -130,69 +79,47 @@ internal class ReleaseUpdateEmailService(
|
|||||||
return sentCount;
|
return sentCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyCollection<User>> GetTestRecipientsAsync(Guid senderUserId, CancellationToken ct)
|
private async Task<List<User>> GetReleaseNoteRecipientsAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
User? sender = await userManager.Users.SingleOrDefaultAsync(user => user.Id == senderUserId, ct);
|
return await userManager.Users
|
||||||
return sender is null ? [] : [sender];
|
.Where(user => user.EmailConfirmed && user.Email != null)
|
||||||
|
.OrderBy(user => user.Email)
|
||||||
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<User>> GetAudienceRecipientsAsync(ReleaseUpdateAudience audience, CancellationToken ct)
|
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates, string? preferredLanguage)
|
||||||
{
|
|
||||||
IQueryable<User> query = userManager.Users.Where(user => user.EmailConfirmed && user.Email != null);
|
|
||||||
|
|
||||||
if (audience == ReleaseUpdateAudience.Developers)
|
|
||||||
{
|
|
||||||
IList<User> developers = await userManager.GetUsersInRoleAsync(KnownRoles.Developer);
|
|
||||||
return developers.Where(user => user.EmailConfirmed && !string.IsNullOrWhiteSpace(user.Email)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audience == ReleaseUpdateAudience.OrganizationOwners)
|
|
||||||
{
|
|
||||||
Guid[] ownerUserIds = await dbContext.Organizations
|
|
||||||
.Select(organization => organization.OwnerUserId)
|
|
||||||
.Concat(dbContext.OrganizationMemberships
|
|
||||||
.Where(membership => membership.Role == OrganizationRoles.Owner)
|
|
||||||
.Select(membership => membership.UserId))
|
|
||||||
.Distinct()
|
|
||||||
.ToArrayAsync(ct);
|
|
||||||
|
|
||||||
query = query.Where(user => ownerUserIds.Contains(user.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query.OrderBy(user => user.Email).ToListAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string BuildSingleUpdateEmail(ReleaseUpdate update)
|
|
||||||
{
|
|
||||||
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates?updateId={update.Id}";
|
|
||||||
return $"""
|
|
||||||
<h1>{HtmlEncode(update.Title)}</h1>
|
|
||||||
<p><strong>{HtmlEncode(update.Category.ToString())}</strong></p>
|
|
||||||
<p>{HtmlEncode(update.Summary)}</p>
|
|
||||||
{FormatBody(update.Body)}
|
|
||||||
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates)
|
|
||||||
{
|
{
|
||||||
|
bool useFrench = IsFrench(preferredLanguage);
|
||||||
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
|
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
|
||||||
string listItems = string.Join(
|
string listItems = string.Join(
|
||||||
Environment.NewLine,
|
Environment.NewLine,
|
||||||
updates.Select(update => $"<li><strong>{HtmlEncode(update.Title)}</strong><br>{HtmlEncode(update.Summary)}</li>"));
|
updates.Select(update => $"""
|
||||||
|
<li>
|
||||||
|
<strong>{HtmlEncode(useFrench ? update.TitleFr : update.Title)}</strong><br>
|
||||||
|
{HtmlEncode(useFrench ? update.SummaryFr : update.Summary)}
|
||||||
|
</li>
|
||||||
|
"""));
|
||||||
|
|
||||||
|
string heading = useFrench ? "Nouveautes dans Socialize" : "What's new in Socialize";
|
||||||
|
string linkText = useFrench ? "Ouvrir les nouveautes" : "Open What's New";
|
||||||
|
|
||||||
return $"""
|
return $"""
|
||||||
<h1>What's new in Socialize</h1>
|
<h1>{HtmlEncode(heading)}</h1>
|
||||||
<ul>{listItems}</ul>
|
<ul>{listItems}</ul>
|
||||||
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
|
<p><a href="{HtmlEncode(updateUrl)}">{HtmlEncode(linkText)}</a></p>
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatBody(string? body)
|
private static string GetDigestSubject(string? preferredLanguage)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(body)
|
return IsFrench(preferredLanguage)
|
||||||
? string.Empty
|
? "Nouveautes dans Socialize"
|
||||||
: $"<p>{HtmlEncode(body).Replace(Environment.NewLine, "<br>", StringComparison.Ordinal)}</p>";
|
: "What's new in Socialize";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsFrench(string? preferredLanguage)
|
||||||
|
{
|
||||||
|
return string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string HtmlEncode(string? value)
|
private static string HtmlEncode(string? value)
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
|
||||||
|
|
||||||
internal static class ReleaseUpdateRules
|
|
||||||
{
|
|
||||||
public static bool TryParseCategory(string value, out ReleaseUpdateCategory category)
|
|
||||||
{
|
|
||||||
return TryParseEnum(value, out category);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryParseImportance(string value, out ReleaseUpdateImportance importance)
|
|
||||||
{
|
|
||||||
return TryParseEnum(value, out importance);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryParseAudience(string value, out ReleaseUpdateAudience audience)
|
|
||||||
{
|
|
||||||
return TryParseEnum(value, out audience);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryParseEnum<TEnum>(string value, out TEnum result)
|
|
||||||
where TEnum : struct
|
|
||||||
{
|
|
||||||
string normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal);
|
|
||||||
return Enum.TryParse(normalized, ignoreCase: true, out result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +1,11 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Socialize.Api.Data;
|
|
||||||
using Socialize.Api.Modules.Identity.Contracts;
|
|
||||||
using Socialize.Api.Modules.Organizations.Data;
|
|
||||||
using Socialize.Api.Modules.Organizations.Services;
|
|
||||||
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
using Socialize.Api.Modules.ReleaseCommunications.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||||
|
|
||||||
internal static class ReleaseUpdateVisibility
|
internal static class ReleaseUpdateVisibility
|
||||||
{
|
{
|
||||||
public static async Task<ReleaseUpdateAudienceContext> GetAudienceContextAsync(
|
public static IQueryable<ReleaseUpdate> VisibleToUsers(this IQueryable<ReleaseUpdate> query)
|
||||||
AppDbContext dbContext,
|
|
||||||
ClaimsPrincipal user,
|
|
||||||
Guid userId,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
bool isDeveloper = user.IsInRole(KnownRoles.Developer);
|
return query.Where(update => update.Status == ReleaseUpdateStatus.Published);
|
||||||
bool isOrganizationOwner = await dbContext.Organizations.AnyAsync(
|
|
||||||
organization => organization.OwnerUserId == userId,
|
|
||||||
ct)
|
|
||||||
|| await dbContext.OrganizationMemberships.AnyAsync(
|
|
||||||
membership =>
|
|
||||||
membership.UserId == userId &&
|
|
||||||
membership.Role == OrganizationRoles.Owner,
|
|
||||||
ct);
|
|
||||||
|
|
||||||
return new ReleaseUpdateAudienceContext(isDeveloper, isOrganizationOwner);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IQueryable<ReleaseUpdate> VisibleTo(
|
|
||||||
this IQueryable<ReleaseUpdate> query,
|
|
||||||
ReleaseUpdateAudienceContext context)
|
|
||||||
{
|
|
||||||
return query.Where(update =>
|
|
||||||
update.Status == ReleaseUpdateStatus.Published &&
|
|
||||||
(update.Audience == ReleaseUpdateAudience.Everyone ||
|
|
||||||
(update.Audience == ReleaseUpdateAudience.OrganizationOwners && context.IsOrganizationOwner) ||
|
|
||||||
(update.Audience == ReleaseUpdateAudience.Developers && context.IsDeveloper)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal record ReleaseUpdateAudienceContext(
|
|
||||||
bool IsDeveloper,
|
|
||||||
bool IsOrganizationOwner);
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.Workspaces.Data;
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ internal class CreateWorkspaceRequestValidator
|
|||||||
|
|
||||||
internal class CreateWorkspaceHandler(
|
internal class CreateWorkspaceHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
AccessScopeService accessScopeService)
|
AccessScopeService accessScopeService,
|
||||||
|
SocializeMetrics metrics)
|
||||||
: Endpoint<CreateWorkspaceRequest, WorkspaceDto>
|
: Endpoint<CreateWorkspaceRequest, WorkspaceDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -65,6 +67,7 @@ internal class CreateWorkspaceHandler(
|
|||||||
|
|
||||||
dbContext.Workspaces.Add(workspace);
|
dbContext.Workspaces.Add(workspace);
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
metrics.RecordWorkspaceCreated(workspace.OrganizationId, workspace.Id);
|
||||||
|
|
||||||
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, []);
|
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.Identity.Contracts;
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
using Socialize.Api.Modules.Workspaces.Data;
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
@@ -31,7 +32,8 @@ internal class CreateWorkspaceInviteRequestValidator
|
|||||||
|
|
||||||
internal class CreateWorkspaceInviteHandler(
|
internal class CreateWorkspaceInviteHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
AccessScopeService accessScopeService)
|
AccessScopeService accessScopeService,
|
||||||
|
SocializeMetrics metrics)
|
||||||
: Endpoint<CreateWorkspaceInviteRequest, WorkspaceInviteDto>
|
: Endpoint<CreateWorkspaceInviteRequest, WorkspaceInviteDto>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -91,6 +93,7 @@ internal class CreateWorkspaceInviteHandler(
|
|||||||
|
|
||||||
dbContext.WorkspaceInvites.Add(invite);
|
dbContext.WorkspaceInvites.Add(invite);
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
metrics.RecordWorkspaceInviteCreated(invite.WorkspaceId, invite.Role);
|
||||||
|
|
||||||
await SendAsync(
|
await SendAsync(
|
||||||
new WorkspaceInviteDto(
|
new WorkspaceInviteDto(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Socialize;
|
|||||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||||
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||||
using Socialize.Api.Infrastructure;
|
using Socialize.Api.Infrastructure;
|
||||||
|
using Socialize.Api.Infrastructure.Observability;
|
||||||
using Socialize.Api.Infrastructure.TestData;
|
using Socialize.Api.Infrastructure.TestData;
|
||||||
using Socialize.Api.Modules.Approvals;
|
using Socialize.Api.Modules.Approvals;
|
||||||
using Socialize.Api.Modules.Assets;
|
using Socialize.Api.Modules.Assets;
|
||||||
@@ -44,6 +45,8 @@ builder.Services.AddCors(options =>
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
builder.AddObservability();
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddWebServices();
|
builder.Services.AddWebServices();
|
||||||
builder.Services.AddAuthorizationAndAuthentication(builder.Configuration);
|
builder.Services.AddAuthorizationAndAuthentication(builder.Configuration);
|
||||||
@@ -110,6 +113,7 @@ app.UseCors("AllowAll");
|
|||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseObservabilityLoggingScope();
|
||||||
|
|
||||||
// Initialize and seed the db.
|
// Initialize and seed the db.
|
||||||
await app.UseAppDataAsync();
|
await app.UseAppDataAsync();
|
||||||
@@ -122,7 +126,7 @@ if (!app.Environment.IsDevelopment())
|
|||||||
app.UseHsts();
|
app.UseHsts();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHealthChecks("/health");
|
app.MapObservabilityHealthChecks();
|
||||||
|
|
||||||
LocalBlobStorageOptions localBlobStorageOptions = app.Services
|
LocalBlobStorageOptions localBlobStorageOptions = app.Services
|
||||||
.GetRequiredService<IOptions<LocalBlobStorageOptions>>()
|
.GetRequiredService<IOptions<LocalBlobStorageOptions>>()
|
||||||
|
|||||||
@@ -28,7 +28,13 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"
|
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"
|
||||||
Version="10.0.0" />
|
Version="10.0.0" />
|
||||||
|
<PackageReference Include="Npgsql.OpenTelemetry" Version="10.0.2" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@@ -6,64 +6,16 @@ namespace Socialize.Tests.ReleaseCommunications;
|
|||||||
|
|
||||||
public class ReleaseUpdateRulesTests
|
public class ReleaseUpdateRulesTests
|
||||||
{
|
{
|
||||||
[Theory]
|
|
||||||
[InlineData("Feature", ReleaseUpdateCategory.Feature)]
|
|
||||||
[InlineData("improvement", ReleaseUpdateCategory.Improvement)]
|
|
||||||
[InlineData("Breaking Change", ReleaseUpdateCategory.BreakingChange)]
|
|
||||||
[InlineData("BreakingChange", ReleaseUpdateCategory.BreakingChange)]
|
|
||||||
internal void TryParseCategory_accepts_supported_categories(string value, ReleaseUpdateCategory expected)
|
|
||||||
{
|
|
||||||
bool parsed = ReleaseUpdateRules.TryParseCategory(value, out ReleaseUpdateCategory category);
|
|
||||||
|
|
||||||
Assert.True(parsed);
|
|
||||||
Assert.Equal(expected, category);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("")]
|
|
||||||
[InlineData("Security")]
|
|
||||||
[InlineData("Maintenance")]
|
|
||||||
public void TryParseCategory_rejects_unsupported_categories(string value)
|
|
||||||
{
|
|
||||||
bool parsed = ReleaseUpdateRules.TryParseCategory(value, out _);
|
|
||||||
|
|
||||||
Assert.False(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("Normal", ReleaseUpdateImportance.Normal)]
|
|
||||||
[InlineData("important", ReleaseUpdateImportance.Important)]
|
|
||||||
internal void TryParseImportance_accepts_supported_importance(string value, ReleaseUpdateImportance expected)
|
|
||||||
{
|
|
||||||
bool parsed = ReleaseUpdateRules.TryParseImportance(value, out ReleaseUpdateImportance importance);
|
|
||||||
|
|
||||||
Assert.True(parsed);
|
|
||||||
Assert.Equal(expected, importance);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("Everyone", ReleaseUpdateAudience.Everyone)]
|
|
||||||
[InlineData("Organization Owners", ReleaseUpdateAudience.OrganizationOwners)]
|
|
||||||
[InlineData("developers", ReleaseUpdateAudience.Developers)]
|
|
||||||
internal void TryParseAudience_accepts_supported_audiences(string value, ReleaseUpdateAudience expected)
|
|
||||||
{
|
|
||||||
bool parsed = ReleaseUpdateRules.TryParseAudience(value, out ReleaseUpdateAudience audience);
|
|
||||||
|
|
||||||
Assert.True(parsed);
|
|
||||||
Assert.Equal(expected, audience);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToDto_formats_breaking_change_category_for_display()
|
public void ToDto_maps_summary_to_description()
|
||||||
{
|
{
|
||||||
ReleaseUpdate update = new()
|
ReleaseUpdate update = new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Title = "API change",
|
Title = "API change",
|
||||||
Summary = "A workflow API changed.",
|
Summary = "A workflow API changed.",
|
||||||
Category = ReleaseUpdateCategory.BreakingChange,
|
TitleFr = "Changement API",
|
||||||
Importance = ReleaseUpdateImportance.Important,
|
SummaryFr = "Une API du flux de travail a change.",
|
||||||
Audience = ReleaseUpdateAudience.Developers,
|
|
||||||
Status = ReleaseUpdateStatus.Published,
|
Status = ReleaseUpdateStatus.Published,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
@@ -73,18 +25,22 @@ public class ReleaseUpdateRulesTests
|
|||||||
|
|
||||||
ReleaseUpdateDto dto = update.ToDto(isRead: true);
|
ReleaseUpdateDto dto = update.ToDto(isRead: true);
|
||||||
|
|
||||||
Assert.Equal("Breaking Change", dto.Category);
|
Assert.Equal("A workflow API changed.", dto.Description);
|
||||||
|
Assert.Equal("API change", dto.TitleEn);
|
||||||
|
Assert.Equal("A workflow API changed.", dto.DescriptionEn);
|
||||||
|
Assert.Equal("Changement API", dto.TitleFr);
|
||||||
|
Assert.Equal("Une API du flux de travail a change.", dto.DescriptionFr);
|
||||||
Assert.True(dto.IsRead);
|
Assert.True(dto.IsRead);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VisibleTo_returns_everyone_updates_for_any_authenticated_user()
|
public void VisibleToUsers_returns_published_updates()
|
||||||
{
|
{
|
||||||
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
|
ReleaseUpdate update = NewPublishedUpdate();
|
||||||
|
|
||||||
List<ReleaseUpdate> visibleUpdates = new[] { update }
|
List<ReleaseUpdate> visibleUpdates = new[] { update }
|
||||||
.AsQueryable()
|
.AsQueryable()
|
||||||
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: false))
|
.VisibleToUsers()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Assert.Same(update, Assert.Single(visibleUpdates));
|
Assert.Same(update, Assert.Single(visibleUpdates));
|
||||||
@@ -93,37 +49,17 @@ public class ReleaseUpdateRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void VisibleTo_rejects_unpublished_updates()
|
public void VisibleTo_rejects_unpublished_updates()
|
||||||
{
|
{
|
||||||
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
|
ReleaseUpdate update = NewPublishedUpdate();
|
||||||
update.Status = ReleaseUpdateStatus.Draft;
|
update.Status = ReleaseUpdateStatus.Draft;
|
||||||
|
|
||||||
List<ReleaseUpdate> visibleUpdates = new[] { update }
|
List<ReleaseUpdate> visibleUpdates = new[] { update }
|
||||||
.AsQueryable()
|
.AsQueryable()
|
||||||
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: true))
|
.VisibleToUsers()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Assert.Empty(visibleUpdates);
|
Assert.Empty(visibleUpdates);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void VisibleTo_requires_matching_restricted_audience()
|
|
||||||
{
|
|
||||||
ReleaseUpdate ownerUpdate = NewPublishedUpdate(ReleaseUpdateAudience.OrganizationOwners);
|
|
||||||
ReleaseUpdate developerUpdate = NewPublishedUpdate(ReleaseUpdateAudience.Developers);
|
|
||||||
|
|
||||||
List<ReleaseUpdate> ownerVisibleUpdates = new[] { ownerUpdate, developerUpdate }
|
|
||||||
.AsQueryable()
|
|
||||||
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: true))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
List<ReleaseUpdate> developerVisibleUpdates = new[] { ownerUpdate, developerUpdate }
|
|
||||||
.AsQueryable()
|
|
||||||
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: false))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
Assert.Same(ownerUpdate, Assert.Single(ownerVisibleUpdates));
|
|
||||||
Assert.Same(developerUpdate, Assert.Single(developerVisibleUpdates));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CreateMissingReadReceipts_creates_receipts_only_for_unread_visible_updates()
|
public void CreateMissingReadReceipts_creates_receipts_only_for_unread_visible_updates()
|
||||||
{
|
{
|
||||||
@@ -172,16 +108,15 @@ public class ReleaseUpdateRulesTests
|
|||||||
Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore));
|
Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ReleaseUpdate NewPublishedUpdate(ReleaseUpdateAudience audience)
|
private static ReleaseUpdate NewPublishedUpdate()
|
||||||
{
|
{
|
||||||
return new ReleaseUpdate
|
return new ReleaseUpdate
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Title = "Update",
|
Title = "Update",
|
||||||
Summary = "Something changed.",
|
Summary = "Something changed.",
|
||||||
Category = ReleaseUpdateCategory.Improvement,
|
TitleFr = "Mise a jour",
|
||||||
Importance = ReleaseUpdateImportance.Normal,
|
SummaryFr = "Quelque chose a change.",
|
||||||
Audience = audience,
|
|
||||||
Status = ReleaseUpdateStatus.Published,
|
Status = ReleaseUpdateStatus.Published,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
|
||||||
|
namespace Socialize.Tests.Security;
|
||||||
|
|
||||||
|
public class AccessScopeServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Manager_role_does_not_grant_workspace_access_without_workspace_scope()
|
||||||
|
{
|
||||||
|
Guid workspaceId = Guid.NewGuid();
|
||||||
|
ClaimsPrincipal user = CreateUser(KnownRoles.Manager);
|
||||||
|
|
||||||
|
Assert.False(AccessScopeService.CanAccessWorkspace(user, workspaceId));
|
||||||
|
Assert.False(AccessScopeService.CanManageWorkspace(user, workspaceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Administrator_role_does_not_grant_workspace_access_without_workspace_scope()
|
||||||
|
{
|
||||||
|
Guid workspaceId = Guid.NewGuid();
|
||||||
|
ClaimsPrincipal user = CreateUser(KnownRoles.Administrator);
|
||||||
|
|
||||||
|
Assert.False(AccessScopeService.CanAccessWorkspace(user, workspaceId));
|
||||||
|
Assert.False(AccessScopeService.CanManageWorkspace(user, workspaceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Manager_can_manage_only_workspaces_in_scope()
|
||||||
|
{
|
||||||
|
Guid workspaceId = Guid.NewGuid();
|
||||||
|
ClaimsPrincipal user = CreateUser(KnownRoles.Manager, new Claim(KnownClaims.WorkspaceScope, workspaceId.ToString()));
|
||||||
|
|
||||||
|
Assert.True(AccessScopeService.CanAccessWorkspace(user, workspaceId));
|
||||||
|
Assert.True(AccessScopeService.CanManageWorkspace(user, workspaceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal CreateUser(string role, params Claim[] claims)
|
||||||
|
{
|
||||||
|
Claim[] baseClaims =
|
||||||
|
[
|
||||||
|
new(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
|
||||||
|
new(ClaimTypes.Role, role),
|
||||||
|
];
|
||||||
|
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(baseClaims.Concat(claims), "Test"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ services:
|
|||||||
image: postgres:16
|
image: postgres:16
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- /etc/socialize/socialize.env
|
- path: /etc/socialize/socialize.env
|
||||||
- .deploy.env
|
- path: .deploy.env
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
@@ -23,8 +24,9 @@ services:
|
|||||||
image: git.mapachotes.com/jbourdon/socialize-api:${SOCIALIZE_IMAGE_TAG}
|
image: git.mapachotes.com/jbourdon/socialize-api:${SOCIALIZE_IMAGE_TAG}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- /etc/socialize/socialize.env
|
- path: /etc/socialize/socialize.env
|
||||||
- .deploy.env
|
- path: .deploy.env
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT}
|
ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT}
|
||||||
ASPNETCORE_URLS: ${ASPNETCORE_URLS}
|
ASPNETCORE_URLS: ${ASPNETCORE_URLS}
|
||||||
|
|||||||
22
deploy/observability/alertmanager/alertmanager.yml
Normal file
22
deploy/observability/alertmanager/alertmanager.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
global:
|
||||||
|
resolve_timeout: 5m
|
||||||
|
|
||||||
|
route:
|
||||||
|
receiver: preprod-webhook
|
||||||
|
group_by:
|
||||||
|
- alertname
|
||||||
|
- service
|
||||||
|
group_wait: 30s
|
||||||
|
group_interval: 5m
|
||||||
|
repeat_interval: 4h
|
||||||
|
routes:
|
||||||
|
- matchers:
|
||||||
|
- severity="critical"
|
||||||
|
receiver: preprod-webhook
|
||||||
|
repeat_interval: 30m
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
- name: preprod-webhook
|
||||||
|
webhook_configs:
|
||||||
|
- url: ${ALERTMANAGER_WEBHOOK_URL}
|
||||||
|
send_resolved: true
|
||||||
95
deploy/observability/alloy/config.alloy
Normal file
95
deploy/observability/alloy/config.alloy
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
logging {
|
||||||
|
level = "info"
|
||||||
|
format = "logfmt"
|
||||||
|
}
|
||||||
|
|
||||||
|
otelcol.receiver.otlp "api" {
|
||||||
|
grpc {
|
||||||
|
endpoint = "0.0.0.0:4317"
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
endpoint = "0.0.0.0:4318"
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
metrics = [otelcol.processor.transform.metric_labels.input]
|
||||||
|
traces = [otelcol.processor.batch.default.input]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
otelcol.processor.transform "metric_labels" {
|
||||||
|
error_mode = "ignore"
|
||||||
|
|
||||||
|
metric_statements {
|
||||||
|
context = "datapoint"
|
||||||
|
statements = [
|
||||||
|
`set(attributes["service.name"], resource.attributes["service.name"])`,
|
||||||
|
`set(attributes["deployment.environment"], resource.attributes["deployment.environment"])`,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
metrics = [otelcol.processor.batch.default.input]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
otelcol.processor.batch "default" {
|
||||||
|
output {
|
||||||
|
metrics = [otelcol.exporter.prometheus.local.input]
|
||||||
|
traces = [otelcol.exporter.otlp.tempo.input]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
otelcol.exporter.prometheus "local" {
|
||||||
|
forward_to = [prometheus.remote_write.local.receiver]
|
||||||
|
}
|
||||||
|
|
||||||
|
prometheus.remote_write "local" {
|
||||||
|
endpoint {
|
||||||
|
url = "http://prometheus:9090/api/v1/write"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
otelcol.exporter.otlp "tempo" {
|
||||||
|
client {
|
||||||
|
endpoint = "tempo:4317"
|
||||||
|
|
||||||
|
tls {
|
||||||
|
insecure = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discovery.docker "linux" {
|
||||||
|
host = "unix:///var/run/docker.sock"
|
||||||
|
}
|
||||||
|
|
||||||
|
discovery.relabel "docker_logs" {
|
||||||
|
targets = []
|
||||||
|
|
||||||
|
rule {
|
||||||
|
source_labels = ["__meta_docker_container_name"]
|
||||||
|
regex = "/(.*)"
|
||||||
|
target_label = "service_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
rule {
|
||||||
|
source_labels = ["__meta_docker_container_label_com_docker_compose_service"]
|
||||||
|
target_label = "compose_service"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loki.source.docker "default" {
|
||||||
|
host = "unix:///var/run/docker.sock"
|
||||||
|
targets = discovery.docker.linux.targets
|
||||||
|
labels = {"platform" = "docker"}
|
||||||
|
relabel_rules = discovery.relabel.docker_logs.rules
|
||||||
|
forward_to = [loki.write.local.receiver]
|
||||||
|
}
|
||||||
|
|
||||||
|
loki.write "local" {
|
||||||
|
endpoint {
|
||||||
|
url = "http://loki:3100/loki/api/v1/push"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
deploy/observability/blackbox/config.yml
Normal file
9
deploy/observability/blackbox/config.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
modules:
|
||||||
|
http_2xx:
|
||||||
|
prober: http
|
||||||
|
timeout: 5s
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
preferred_ip_protocol: ip4
|
||||||
|
valid_status_codes:
|
||||||
|
- 200
|
||||||
13
deploy/observability/caddy/grafana.Caddyfile
Normal file
13
deploy/observability/caddy/grafana.Caddyfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Optional Caddy snippet for exposing Grafana through a protected hostname.
|
||||||
|
# Generate a password hash with:
|
||||||
|
# caddy hash-password --plaintext '<password>'
|
||||||
|
|
||||||
|
{$OBSERVABILITY_HOST} {
|
||||||
|
encode gzip zstd
|
||||||
|
|
||||||
|
basicauth {
|
||||||
|
{$GRAFANA_BASIC_AUTH_USER} {$GRAFANA_BASIC_AUTH_HASH}
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy grafana:3000
|
||||||
|
}
|
||||||
121
deploy/observability/compose.observability.yml
Normal file
121
deploy/observability/compose.observability.yml
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
environment:
|
||||||
|
OTEL_SERVICE_NAME: socialize-api
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://alloy:4317
|
||||||
|
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||||
|
OTEL_RESOURCE_ATTRIBUTES: deployment.environment=preprod
|
||||||
|
depends_on:
|
||||||
|
alloy:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:13.0.1
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
ports:
|
||||||
|
- "${GRAFANA_HTTP_BIND:-127.0.0.1}:3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
- loki
|
||||||
|
- tempo
|
||||||
|
- alertmanager
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v3.11.3
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
- --storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-15d}
|
||||||
|
- --web.enable-remote-write-receiver
|
||||||
|
volumes:
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
- ./observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- ./observability/prometheus/rules:/etc/prometheus/rules:ro
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
alertmanager:
|
||||||
|
image: prom/alertmanager:v0.29.0
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/alertmanager/alertmanager.yml
|
||||||
|
- --storage.path=/alertmanager
|
||||||
|
- --config.expand-env
|
||||||
|
environment:
|
||||||
|
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-http://127.0.0.1:9/}
|
||||||
|
volumes:
|
||||||
|
- alertmanager-data:/alertmanager
|
||||||
|
- ./observability/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
blackbox:
|
||||||
|
image: prom/blackbox-exporter:v0.27.0
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/blackbox_exporter/config.yml
|
||||||
|
volumes:
|
||||||
|
- ./observability/blackbox/config.yml:/etc/blackbox_exporter/config.yml:ro
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:3.7.1
|
||||||
|
restart: unless-stopped
|
||||||
|
command: -config.file=/etc/loki/local-config.yml
|
||||||
|
volumes:
|
||||||
|
- loki-data:/loki
|
||||||
|
- ./observability/loki/local-config.yml:/etc/loki/local-config.yml:ro
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
tempo:
|
||||||
|
image: grafana/tempo:2.10.3
|
||||||
|
restart: unless-stopped
|
||||||
|
command: -config.file=/etc/tempo.yml
|
||||||
|
volumes:
|
||||||
|
- tempo-data:/var/tempo
|
||||||
|
- ./observability/tempo/tempo.yml:/etc/tempo.yml:ro
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
alloy:
|
||||||
|
image: grafana/alloy:v1.16.0
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- run
|
||||||
|
- --server.http.listen-addr=0.0.0.0:12345
|
||||||
|
- --storage.path=/var/lib/alloy/data
|
||||||
|
- /etc/alloy/config.alloy
|
||||||
|
volumes:
|
||||||
|
- alloy-data:/var/lib/alloy/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./observability/alloy/config.alloy:/etc/alloy/config.alloy:ro
|
||||||
|
expose:
|
||||||
|
- "4317"
|
||||||
|
- "4318"
|
||||||
|
- "12345"
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
alertmanager-data:
|
||||||
|
grafana-data:
|
||||||
|
prometheus-data:
|
||||||
|
loki-data:
|
||||||
|
tempo-data:
|
||||||
|
alloy-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
485
deploy/observability/grafana/dashboards/socialize-overview.json
Normal file
485
deploy/observability/grafana/dashboards/socialize-overview.json
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(http_server_request_duration_seconds_count{service_name=\"socialize-api\"}[5m]))",
|
||||||
|
"legendFormat": "requests/sec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "API Requests/sec",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 6,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(http_server_request_duration_seconds_count{service_name=\"socialize-api\", http_response_status_code=~\"5..\"}[5m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count{service_name=\"socialize-api\"}[5m])), 0.001)",
|
||||||
|
"legendFormat": "5xx rate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "API 5xx Rate",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 12,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_request_duration_seconds_bucket{service_name=\"socialize-api\"}[5m])))",
|
||||||
|
"legendFormat": "p95"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "API p95 Latency",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 6,
|
||||||
|
"x": 18,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 4,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(ALERTS{alertstate=\"firing\"})",
|
||||||
|
"legendFormat": "firing"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Firing Alerts",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 4
|
||||||
|
},
|
||||||
|
"id": 5,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(http_server_request_duration_seconds_count{service_name=\"socialize-api\"}[5m])) by (http_request_method, http_route)",
|
||||||
|
"legendFormat": "{{http_request_method}} {{http_route}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Request Rate By Endpoint",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 4
|
||||||
|
},
|
||||||
|
"id": 6,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.95, sum by (le, http_route) (rate(http_server_request_duration_seconds_bucket{service_name=\"socialize-api\"}[5m])))",
|
||||||
|
"legendFormat": "{{http_route}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "p95 Latency By Endpoint",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 12
|
||||||
|
},
|
||||||
|
"id": 7,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(socialize_login_attempts_total[24h])) by (outcome)",
|
||||||
|
"legendFormat": "login {{outcome}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(socialize_organizations_created_total[24h]))",
|
||||||
|
"legendFormat": "organizations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(socialize_workspaces_created_total[24h]))",
|
||||||
|
"legendFormat": "workspaces"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(socialize_content_items_created_total[24h]))",
|
||||||
|
"legendFormat": "content"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(socialize_comments_created_total[24h]))",
|
||||||
|
"legendFormat": "comments"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(socialize_approval_decisions_submitted_total[24h]))",
|
||||||
|
"legendFormat": "approvals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(socialize_feedback_submitted_total[24h]))",
|
||||||
|
"legendFormat": "feedback"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Usage Signals, 24h Rolling",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 12
|
||||||
|
},
|
||||||
|
"id": 8,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(socialize_email_delivery_total[1h])) by (outcome, provider)",
|
||||||
|
"legendFormat": "email {{provider}} {{outcome}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(socialize_blob_storage_operations_total[1h])) by (operation, outcome)",
|
||||||
|
"legendFormat": "blob {{operation}} {{outcome}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(socialize_background_job_runs_total[1h])) by (job, outcome)",
|
||||||
|
"legendFormat": "job {{job}} {{outcome}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Operational Events, 1h Rolling",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 20
|
||||||
|
},
|
||||||
|
"id": 11,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "socialize_workflow_content_items",
|
||||||
|
"legendFormat": "content {{status}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "socialize_workflow_feedback_reports",
|
||||||
|
"legendFormat": "feedback {{status}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Workflow Backlog",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 20
|
||||||
|
},
|
||||||
|
"id": 12,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "socialize_workflow_active_workspaces",
|
||||||
|
"legendFormat": "active workspaces {{window}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "socialize_workflow_stale_in_approval",
|
||||||
|
"legendFormat": "stale in approval"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "socialize_workflow_pending_invites",
|
||||||
|
"legendFormat": "pending invites"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Workflow Health",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "Prometheus"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 28
|
||||||
|
},
|
||||||
|
"id": 9,
|
||||||
|
"options": {
|
||||||
|
"showHeader": true
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "ALERTS{alertstate=\"firing\"}",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "{{alertname}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Firing Alerts",
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "Loki"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 35
|
||||||
|
},
|
||||||
|
"id": 10,
|
||||||
|
"options": {
|
||||||
|
"dedupStrategy": "none",
|
||||||
|
"enableLogDetails": true,
|
||||||
|
"prettifyLogMessage": false,
|
||||||
|
"showCommonLabels": false,
|
||||||
|
"showLabels": false,
|
||||||
|
"showTime": true,
|
||||||
|
"sortOrder": "Descending",
|
||||||
|
"wrapLogMessage": false
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "{platform=\"docker\", compose_service=\"api\"}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "API Logs",
|
||||||
|
"type": "logs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"tags": [
|
||||||
|
"socialize",
|
||||||
|
"preprod"
|
||||||
|
],
|
||||||
|
"templating": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "",
|
||||||
|
"title": "Socialize Overview",
|
||||||
|
"uid": "socialize-overview",
|
||||||
|
"version": 2,
|
||||||
|
"weekStart": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: Socialize
|
||||||
|
orgId: 1
|
||||||
|
folder: Socialize
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 30
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
uid: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
|
||||||
|
- name: Loki
|
||||||
|
uid: Loki
|
||||||
|
type: loki
|
||||||
|
access: proxy
|
||||||
|
url: http://loki:3100
|
||||||
|
|
||||||
|
- name: Tempo
|
||||||
|
uid: Tempo
|
||||||
|
type: tempo
|
||||||
|
access: proxy
|
||||||
|
url: http://tempo:3200
|
||||||
|
jsonData:
|
||||||
|
tracesToLogsV2:
|
||||||
|
datasourceUid: Loki
|
||||||
|
serviceMap:
|
||||||
|
datasourceUid: Prometheus
|
||||||
32
deploy/observability/loki/local-config.yml
Normal file
32
deploy/observability/loki/local-config.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
auth_enabled: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
http_listen_port: 3100
|
||||||
|
|
||||||
|
common:
|
||||||
|
path_prefix: /loki
|
||||||
|
replication_factor: 1
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
|
||||||
|
schema_config:
|
||||||
|
configs:
|
||||||
|
- from: 2024-01-01
|
||||||
|
store: tsdb
|
||||||
|
object_store: filesystem
|
||||||
|
schema: v13
|
||||||
|
index:
|
||||||
|
prefix: index_
|
||||||
|
period: 24h
|
||||||
|
|
||||||
|
storage_config:
|
||||||
|
filesystem:
|
||||||
|
directory: /loki/chunks
|
||||||
|
|
||||||
|
limits_config:
|
||||||
|
allow_structured_metadata: true
|
||||||
|
volume_enabled: true
|
||||||
|
|
||||||
|
analytics:
|
||||||
|
reporting_enabled: false
|
||||||
42
deploy/observability/prometheus/prometheus.yml
Normal file
42
deploy/observability/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets:
|
||||||
|
- alertmanager:9093
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/rules/*.yml
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- prometheus:9090
|
||||||
|
|
||||||
|
- job_name: alloy
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- alloy:12345
|
||||||
|
|
||||||
|
- job_name: preprod-uptime
|
||||||
|
metrics_path: /probe
|
||||||
|
params:
|
||||||
|
module:
|
||||||
|
- http_2xx
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- http://web/
|
||||||
|
- http://api:8080/health/ready
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels:
|
||||||
|
- __address__
|
||||||
|
target_label: __param_target
|
||||||
|
- source_labels:
|
||||||
|
- __param_target
|
||||||
|
target_label: instance
|
||||||
|
- target_label: __address__
|
||||||
|
replacement: blackbox:9115
|
||||||
127
deploy/observability/prometheus/rules/socialize-alerts.yml
Normal file
127
deploy/observability/prometheus/rules/socialize-alerts.yml
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
groups:
|
||||||
|
- name: socialize-preprod
|
||||||
|
rules:
|
||||||
|
- alert: SocializeApiTelemetryMissing
|
||||||
|
expr: absent(http_server_request_duration_seconds_count{service_name="socialize-api"})
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
service: socialize-api
|
||||||
|
annotations:
|
||||||
|
summary: Socialize API telemetry is missing
|
||||||
|
description: No API request telemetry has been received for 5 minutes. The API or telemetry pipeline may be down.
|
||||||
|
|
||||||
|
- alert: SocializePreprodEndpointDown
|
||||||
|
expr: probe_success{job="preprod-uptime"} == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
service: socialize-preprod
|
||||||
|
annotations:
|
||||||
|
summary: Preprod endpoint is down
|
||||||
|
description: '{{ $labels.instance }} has failed blackbox checks for 2 minutes.'
|
||||||
|
|
||||||
|
- alert: SocializeApiHighErrorRate
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
sum(rate(http_server_request_duration_seconds_count{service_name="socialize-api", http_response_status_code=~"5.."}[5m]))
|
||||||
|
/
|
||||||
|
clamp_min(sum(rate(http_server_request_duration_seconds_count{service_name="socialize-api"}[5m])), 0.001)
|
||||||
|
) > 0.05
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
service: socialize-api
|
||||||
|
annotations:
|
||||||
|
summary: Socialize API 5xx rate is high
|
||||||
|
description: More than 5% of API requests are returning 5xx responses over 5 minutes.
|
||||||
|
|
||||||
|
- alert: SocializeApiHighLatency
|
||||||
|
expr: |
|
||||||
|
histogram_quantile(
|
||||||
|
0.95,
|
||||||
|
sum by (le) (rate(http_server_request_duration_seconds_bucket{service_name="socialize-api"}[5m]))
|
||||||
|
) > 2
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: socialize-api
|
||||||
|
annotations:
|
||||||
|
summary: Socialize API p95 latency is high
|
||||||
|
description: API p95 latency has been above 2 seconds for 10 minutes.
|
||||||
|
|
||||||
|
- alert: SocializeCoreUsageQuiet
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
sum(increase(socialize_content_items_created_total[12h]))
|
||||||
|
+ sum(increase(socialize_comments_created_total[12h]))
|
||||||
|
+ sum(increase(socialize_approval_decisions_submitted_total[12h]))
|
||||||
|
+ sum(increase(socialize_feedback_submitted_total[12h]))
|
||||||
|
) < 1
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: socialize-api
|
||||||
|
annotations:
|
||||||
|
summary: Socialize core usage is quiet
|
||||||
|
description: No content, comment, approval, or feedback activity has been observed over the last 12 hours.
|
||||||
|
|
||||||
|
- alert: SocializeContentStaleInApproval
|
||||||
|
expr: socialize_workflow_stale_in_approval > 0
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: socialize-api
|
||||||
|
annotations:
|
||||||
|
summary: Content is stale in approval
|
||||||
|
description: One or more content items have been in approval longer than the configured threshold.
|
||||||
|
|
||||||
|
- alert: SocializeNoActiveWorkspaces
|
||||||
|
expr: socialize_workflow_active_workspaces{window="24h"} < 1
|
||||||
|
for: 1h
|
||||||
|
labels:
|
||||||
|
severity: info
|
||||||
|
service: socialize-api
|
||||||
|
annotations:
|
||||||
|
summary: No active workspaces in the last 24 hours
|
||||||
|
description: No workspace has content workflow activity in the last 24 hours.
|
||||||
|
|
||||||
|
- alert: SocializeFeedbackBugSubmitted
|
||||||
|
expr: sum(increase(socialize_feedback_submitted_total{feedback_type="Bug"}[15m])) > 0
|
||||||
|
for: 0m
|
||||||
|
labels:
|
||||||
|
severity: info
|
||||||
|
service: socialize-api
|
||||||
|
annotations:
|
||||||
|
summary: New bug feedback submitted
|
||||||
|
description: A user submitted bug feedback in the last 15 minutes.
|
||||||
|
|
||||||
|
- alert: SocializeEmailDeliveryFailures
|
||||||
|
expr: sum(increase(socialize_email_delivery_total{outcome="failure"}[15m])) > 0
|
||||||
|
for: 0m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: socialize-api
|
||||||
|
annotations:
|
||||||
|
summary: Email delivery failures detected
|
||||||
|
description: One or more email delivery attempts failed in the last 15 minutes.
|
||||||
|
|
||||||
|
- alert: SocializeBlobStorageFailures
|
||||||
|
expr: sum(increase(socialize_blob_storage_operations_total{outcome="failure"}[15m])) > 0
|
||||||
|
for: 0m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: socialize-api
|
||||||
|
annotations:
|
||||||
|
summary: Blob storage failures detected
|
||||||
|
description: One or more blob storage operations failed in the last 15 minutes.
|
||||||
|
|
||||||
|
- alert: SocializeBackgroundJobFailures
|
||||||
|
expr: sum(increase(socialize_background_job_runs_total{outcome="failure"}[30m])) > 0
|
||||||
|
for: 0m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: socialize-api
|
||||||
|
annotations:
|
||||||
|
summary: Background job failures detected
|
||||||
|
description: One or more background jobs failed in the last 30 minutes.
|
||||||
25
deploy/observability/tempo/tempo.yml
Normal file
25
deploy/observability/tempo/tempo.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
server:
|
||||||
|
http_listen_port: 3200
|
||||||
|
|
||||||
|
distributor:
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
http:
|
||||||
|
endpoint: 0.0.0.0:4318
|
||||||
|
|
||||||
|
storage:
|
||||||
|
trace:
|
||||||
|
backend: local
|
||||||
|
local:
|
||||||
|
path: /var/tempo/traces
|
||||||
|
|
||||||
|
compactor:
|
||||||
|
compaction:
|
||||||
|
block_retention: 168h
|
||||||
|
|
||||||
|
metrics_generator:
|
||||||
|
storage:
|
||||||
|
path: /var/tempo/generator/wal
|
||||||
94
docs/FEATURES/observability.md
Normal file
94
docs/FEATURES/observability.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Observability
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Draft
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Give the SaaS operator preproduction visibility into whether Socialize is healthy and whether real users are exercising core workflows.
|
||||||
|
|
||||||
|
This feature is operator-facing. It is not a client-facing analytics suite or status page.
|
||||||
|
|
||||||
|
## Initial Scope
|
||||||
|
|
||||||
|
- structured backend logs suitable for centralized log search
|
||||||
|
- OpenTelemetry traces and metrics emitted by the API
|
||||||
|
- self-hosted Grafana observability stack for preproduction
|
||||||
|
- health, readiness, and liveness endpoints
|
||||||
|
- aggregate product usage counters for core workflow actions
|
||||||
|
- dashboards and alerts for app health and adoption signals
|
||||||
|
|
||||||
|
## Operational Signals
|
||||||
|
|
||||||
|
Health signals should cover:
|
||||||
|
|
||||||
|
- API availability
|
||||||
|
- Postgres connectivity
|
||||||
|
- request rate, latency, and error rate
|
||||||
|
- slow endpoints
|
||||||
|
- outbound HTTP failures
|
||||||
|
- background service failures
|
||||||
|
- email delivery failures
|
||||||
|
- blob storage failures
|
||||||
|
- authentication failures
|
||||||
|
|
||||||
|
Usage signals should cover aggregate counts for:
|
||||||
|
|
||||||
|
- login attempts and successful logins
|
||||||
|
- organizations and workspaces created
|
||||||
|
- content items created
|
||||||
|
- comments created
|
||||||
|
- approval decisions submitted
|
||||||
|
- feedback reports submitted
|
||||||
|
- workspace invites created
|
||||||
|
|
||||||
|
## Privacy And Safety Rules
|
||||||
|
|
||||||
|
- Do not log request bodies, access tokens, refresh tokens, passwords, uploaded file contents, screenshots, or raw customer content.
|
||||||
|
- Usage metrics are aggregate operational signals, not behavioral tracking.
|
||||||
|
- User, organization, and workspace identifiers may be included as structured attributes when already available to backend code.
|
||||||
|
- The first implementation targets preproduction and self-hosted Docker infrastructure only.
|
||||||
|
|
||||||
|
## Deployment Shape
|
||||||
|
|
||||||
|
The application emits OpenTelemetry over OTLP to a local collector.
|
||||||
|
|
||||||
|
The preproduction observability stack runs as an optional Docker Compose overlay with:
|
||||||
|
|
||||||
|
- Grafana for dashboards and alerting
|
||||||
|
- Prometheus for metrics
|
||||||
|
- Loki for logs
|
||||||
|
- Tempo for traces
|
||||||
|
- Grafana Alloy for log collection and telemetry routing
|
||||||
|
|
||||||
|
The normal application compose file must remain usable without the observability overlay.
|
||||||
|
|
||||||
|
## Alerting
|
||||||
|
|
||||||
|
Preproduction alerting should start with local Prometheus alert rules. Notification routing is a separate operational setup step because the first preproduction target may use email, chat, or a private incident channel.
|
||||||
|
|
||||||
|
Initial alerts should cover:
|
||||||
|
|
||||||
|
- app telemetry missing
|
||||||
|
- high API error rate
|
||||||
|
- high API p95 latency
|
||||||
|
- core usage unexpectedly quiet
|
||||||
|
- feedback bug reports submitted
|
||||||
|
- email delivery failures
|
||||||
|
- blob storage failures
|
||||||
|
- background job failures
|
||||||
|
|
||||||
|
## Workflow Health Gauges
|
||||||
|
|
||||||
|
Database-derived workflow health metrics should be sampled periodically instead of emitted per request.
|
||||||
|
|
||||||
|
Initial gauges should cover:
|
||||||
|
|
||||||
|
- content item counts by status
|
||||||
|
- feedback report counts by status
|
||||||
|
- pending workspace invites
|
||||||
|
- content stale in approval
|
||||||
|
- active workspace counts over 24-hour and 7-day windows
|
||||||
|
|
||||||
|
These are operator health signals. They should stay aggregate enough to avoid high-cardinality metric labels.
|
||||||
@@ -112,7 +112,7 @@ The system should not rely only on the user's last login timestamp. Login is one
|
|||||||
- Developer-only release communication back office:
|
- Developer-only release communication back office:
|
||||||
- `/app/developer/updates`
|
- `/app/developer/updates`
|
||||||
- `/app/developer/updates/:id`
|
- `/app/developer/updates/:id`
|
||||||
- `/app/developer/release-commits`
|
- `/app/developer/release-notes`
|
||||||
|
|
||||||
Feature-owned frontend code belongs under:
|
Feature-owned frontend code belongs under:
|
||||||
|
|
||||||
@@ -235,9 +235,8 @@ GET /api/developer/release-updates/{id}
|
|||||||
PUT /api/developer/release-updates/{id}
|
PUT /api/developer/release-updates/{id}
|
||||||
POST /api/developer/release-updates/{id}/publish
|
POST /api/developer/release-updates/{id}/publish
|
||||||
POST /api/developer/release-updates/{id}/archive
|
POST /api/developer/release-updates/{id}/archive
|
||||||
POST /api/developer/release-updates/{id}/send-email
|
|
||||||
GET /api/developer/release-commits
|
GET /api/developer/release-commits
|
||||||
POST /api/developer/release-commits/import
|
POST /api/developer/release-commits/refresh
|
||||||
POST /api/developer/release-commits/{sha}/link
|
POST /api/developer/release-commits/{sha}/link
|
||||||
POST /api/developer/release-commits/{sha}/unlink
|
POST /api/developer/release-commits/{sha}/unlink
|
||||||
POST /api/developer/release-commits/{sha}/internal-only
|
POST /api/developer/release-commits/{sha}/internal-only
|
||||||
|
|||||||
163
docs/OPERATIONS/observability-runbook.md
Normal file
163
docs/OPERATIONS/observability-runbook.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Observability Runbook
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This runbook is for preproduction operation of Socialize's self-hosted observability stack.
|
||||||
|
|
||||||
|
The goal is to answer:
|
||||||
|
|
||||||
|
- Is the app reachable?
|
||||||
|
- Is the API healthy?
|
||||||
|
- Are errors or latency rising?
|
||||||
|
- Are users exercising core workflows?
|
||||||
|
- Are emails, blob storage, and background jobs failing?
|
||||||
|
- Is work getting stuck?
|
||||||
|
|
||||||
|
## Start The Stack
|
||||||
|
|
||||||
|
Run from the repository root on the preproduction host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/compose.yml -f deploy/observability/compose.observability.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Grafana listens on `127.0.0.1:3000` by default. Set `GRAFANA_HTTP_BIND=0.0.0.0`
|
||||||
|
only when Grafana is protected by a reverse proxy, VPN, firewall rule, or SSH tunnel.
|
||||||
|
|
||||||
|
Set these before exposing Grafana:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
GRAFANA_ADMIN_PASSWORD=<strong-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alert Delivery
|
||||||
|
|
||||||
|
Prometheus sends alerts to Alertmanager. Alertmanager sends alerts to the webhook
|
||||||
|
configured by:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ALERTMANAGER_WEBHOOK_URL=<private-alert-webhook-url>
|
||||||
|
```
|
||||||
|
|
||||||
|
If no webhook URL is configured, Alertmanager still starts but alert delivery points
|
||||||
|
to a local discard endpoint.
|
||||||
|
|
||||||
|
Critical alerts repeat every 30 minutes. Other alerts repeat every 4 hours.
|
||||||
|
|
||||||
|
## Secure Grafana With Caddy
|
||||||
|
|
||||||
|
An optional Caddy snippet is available at:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
deploy/observability/caddy/grafana.Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a Caddy password hash:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
caddy hash-password --plaintext '<password>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OBSERVABILITY_HOST=observability.example.com
|
||||||
|
GRAFANA_BASIC_AUTH_USER=<user>
|
||||||
|
GRAFANA_BASIC_AUTH_HASH=<hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep Grafana private unless the hostname is protected.
|
||||||
|
|
||||||
|
## First Bring-Up Checks
|
||||||
|
|
||||||
|
1. Confirm containers are running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/compose.yml -f deploy/observability/compose.observability.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check API health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i http://127.0.0.1:8080/health
|
||||||
|
curl -i http://127.0.0.1:8080/health/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open Grafana and check the `Socialize Overview` dashboard.
|
||||||
|
|
||||||
|
4. Generate a few real actions:
|
||||||
|
|
||||||
|
- log in
|
||||||
|
- create a content item
|
||||||
|
- add a comment
|
||||||
|
- submit feedback
|
||||||
|
- create a workspace invite
|
||||||
|
|
||||||
|
5. Confirm metrics appear in the dashboard:
|
||||||
|
|
||||||
|
- API request rate
|
||||||
|
- usage signals
|
||||||
|
- workflow backlog
|
||||||
|
- operational events
|
||||||
|
|
||||||
|
## Alert Triage
|
||||||
|
|
||||||
|
`SocializePreprodEndpointDown`
|
||||||
|
|
||||||
|
- Check `docker compose ps`.
|
||||||
|
- Check `docker compose logs api web`.
|
||||||
|
- Check `/health/ready`.
|
||||||
|
|
||||||
|
`SocializeApiTelemetryMissing`
|
||||||
|
|
||||||
|
- Check that `api` has `OTEL_EXPORTER_OTLP_ENDPOINT=http://alloy:4317`.
|
||||||
|
- Check `docker compose logs alloy`.
|
||||||
|
- Check whether the API is receiving traffic.
|
||||||
|
|
||||||
|
`SocializeApiHighErrorRate`
|
||||||
|
|
||||||
|
- Open the API logs panel.
|
||||||
|
- Filter by recent `5xx` requests.
|
||||||
|
- Open Tempo traces for slow or failing requests if available.
|
||||||
|
|
||||||
|
`SocializeApiHighLatency`
|
||||||
|
|
||||||
|
- Check the p95 latency by endpoint panel.
|
||||||
|
- Inspect slow traces.
|
||||||
|
- Check database health and recent deploy activity.
|
||||||
|
|
||||||
|
`SocializeEmailDeliveryFailures`
|
||||||
|
|
||||||
|
- Check API logs for Resend failures.
|
||||||
|
- Confirm `RESEND_API_KEY` and `RESEND_FROM_EMAIL`.
|
||||||
|
- Confirm Resend service status outside this stack if needed.
|
||||||
|
|
||||||
|
`SocializeBlobStorageFailures`
|
||||||
|
|
||||||
|
- Confirm `./blob-storage` volume permissions on the preprod host.
|
||||||
|
- Check local disk space.
|
||||||
|
- Check API logs for validation or filesystem errors.
|
||||||
|
|
||||||
|
`SocializeBackgroundJobFailures`
|
||||||
|
|
||||||
|
- Check the operational events panel for the failing job name.
|
||||||
|
- Check API logs for the same time window.
|
||||||
|
|
||||||
|
`SocializeContentStaleInApproval`
|
||||||
|
|
||||||
|
- Use the app to inspect content currently in approval.
|
||||||
|
- Contact the relevant internal owner or client contact outside the app if needed.
|
||||||
|
|
||||||
|
`SocializeCoreUsageQuiet` or `SocializeNoActiveWorkspaces`
|
||||||
|
|
||||||
|
- Confirm whether quiet usage is expected for the period.
|
||||||
|
- If not expected, check login events and API reachability.
|
||||||
|
|
||||||
|
## Retention Defaults
|
||||||
|
|
||||||
|
- Prometheus keeps 15 days by default through `PROMETHEUS_RETENTION`.
|
||||||
|
- Tempo keeps traces for 168 hours.
|
||||||
|
- Loki uses local filesystem storage for preproduction.
|
||||||
|
|
||||||
|
Tune retention before heavy customer usage or long-running demos.
|
||||||
27
docs/TASKS/frontend/002-style-system-baseline.md
Normal file
27
docs/TASKS/frontend/002-style-system-baseline.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Task: Add frontend style system baseline
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Remove app-shell styling drift by routing shared chrome controls through Vuetify components and centralized theme-backed tokens.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Expose reusable CSS variables backed by the Vuetify theme.
|
||||||
|
- Add shared app-shell primitives for navigation buttons, icon buttons, popovers, and menu items.
|
||||||
|
- Replace native shell buttons with Vuetify controls in `App.vue`, `AppBar`, `AppSidebar`, `SidebarUserMenu`, and `WorkspaceSelector`.
|
||||||
|
- Leave feature-screen native button migration to a follow-up task because it crosses many workflows.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
- [x] Tailwind preflight loads before Vuetify styles.
|
||||||
|
- [x] App-owned CSS loads after Vuetify styles.
|
||||||
|
- [x] Shared Vuetify defaults are centralized.
|
||||||
|
- [x] Legacy global native button/card selectors were removed.
|
||||||
|
- [x] App-shell styles use shared theme-backed tokens.
|
||||||
31
docs/TASKS/frontend/003-vuetify-button-migration.md
Normal file
31
docs/TASKS/frontend/003-vuetify-button-migration.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Task: Replace native feature buttons with Vuetify controls
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Move remaining interactive feature-screen buttons from native `<button>` elements to Vuetify controls so button styling consistently flows through Vuetify.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Replace action buttons with `v-btn`.
|
||||||
|
- Replace icon-only buttons with `v-btn` using icon-sized styling.
|
||||||
|
- Preserve specialized non-button native controls only when Vuetify would reduce capability, such as file inputs.
|
||||||
|
- Keep behavior unchanged while converting one feature area at a time.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `frontend/src/components/ImageCropperDialog.vue`
|
||||||
|
- `frontend/src/features/**/**/*.vue`
|
||||||
|
- `frontend/src/static/**/*.vue`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
- [x] Native `<button>` elements under `frontend/src/**/*.vue` were migrated to `v-btn`.
|
||||||
|
- [x] Public SSR rendering installs the shared Vuetify plugin.
|
||||||
|
- [x] Frontend build and public prerender pass.
|
||||||
44
docs/TASKS/observability/001-observability-foundation.md
Normal file
44
docs/TASKS/observability/001-observability-foundation.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Observability 001: Preprod Foundation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add the first preproduction observability foundation for Socialize so the operator can tell whether the app is healthy and whether core workflows are being used.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/observability.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add backend OpenTelemetry registration for traces and metrics.
|
||||||
|
- Add structured JSON console logging with request correlation context.
|
||||||
|
- Add aggregate custom counters for core usage events.
|
||||||
|
- Expand health endpoints with liveness and readiness checks.
|
||||||
|
- Add an optional Docker Compose observability overlay for Grafana, Prometheus, Loki, Tempo, and Alloy.
|
||||||
|
- Add basic Grafana datasource/dashboard provisioning.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Program.cs`
|
||||||
|
- `backend/src/Socialize.Api/ApplicationRegistration.cs`
|
||||||
|
- `backend/src/Socialize.Api/Infrastructure/Observability/*`
|
||||||
|
- selected backend handlers for usage counters
|
||||||
|
- `backend/src/Socialize.Api/Socialize.Api.csproj`
|
||||||
|
- `deploy/observability/*`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
- Client-facing analytics or status page.
|
||||||
|
- Frontend behavioral analytics.
|
||||||
|
- Cloud telemetry providers.
|
||||||
|
- Long-term telemetry retention policy.
|
||||||
|
- Full product analytics warehouse.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
docker compose -f deploy/compose.yml -f deploy/observability/compose.observability.yml config
|
||||||
|
```
|
||||||
32
docs/TASKS/observability/002-alerts-dashboard-hardening.md
Normal file
32
docs/TASKS/observability/002-alerts-dashboard-hardening.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Observability 002: Alerts And Dashboard Hardening
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make the preproduction observability stack actionable by adding alert rules, better operator dashboards, pinned image versions, and operational counters for services that commonly fail silently.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/observability.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Pin Grafana, Prometheus, Loki, Tempo, and Alloy image tags in the observability compose overlay.
|
||||||
|
- Add Prometheus alert rules for API health, error rate, latency, usage silence, feedback bugs, email failures, blob failures, and background job failures.
|
||||||
|
- Expand the Grafana dashboard with health, usage, operational failure, alert, log, and trace-oriented panels.
|
||||||
|
- Add backend counters for email delivery, blob storage operations, and background job runs.
|
||||||
|
- Document alerting and safe Grafana exposure expectations.
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
- Notification delivery integration for alerts.
|
||||||
|
- Client-facing status page.
|
||||||
|
- Cloud observability backends.
|
||||||
|
- Full product analytics or session tracking.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
docker compose -f deploy/compose.yml -f deploy/observability/compose.observability.yml config
|
||||||
|
```
|
||||||
34
docs/TASKS/observability/003-preprod-operations-loop.md
Normal file
34
docs/TASKS/observability/003-preprod-operations-loop.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Observability 003: Preprod Operations Loop
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Close the preproduction operations loop by adding alert delivery scaffolding, uptime probes, workflow health gauges, secured Grafana guidance, and an operator runbook.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/observability.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add Alertmanager to the optional observability compose overlay.
|
||||||
|
- Add Blackbox Exporter uptime probes for the web container and API readiness endpoint.
|
||||||
|
- Add backend database-derived workflow health gauges.
|
||||||
|
- Add Prometheus alerts for uptime probes and workflow health.
|
||||||
|
- Add an optional Caddy snippet for protected Grafana exposure.
|
||||||
|
- Add an operator runbook for bring-up, alert triage, and security defaults.
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
- Operating the remote preproduction host.
|
||||||
|
- Choosing the final alert destination.
|
||||||
|
- Client-facing status page.
|
||||||
|
- External third-party uptime monitoring.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
docker compose -f deploy/compose.yml -f deploy/observability/compose.observability.yml config
|
||||||
|
jq empty deploy/observability/grafana/dashboards/socialize-overview.json
|
||||||
|
```
|
||||||
@@ -24,7 +24,7 @@ Add the developer back-office workflow for importing shipped commits and matchin
|
|||||||
- mark a commit internal-only
|
- mark a commit internal-only
|
||||||
- mark a commit ignored
|
- mark a commit ignored
|
||||||
- Add developer-only frontend screens:
|
- Add developer-only frontend screens:
|
||||||
- `/app/developer/release-commits`
|
- `/app/developer/release-notes`
|
||||||
- linked commits on `/app/developer/updates/:id`
|
- linked commits on `/app/developer/updates/:id`
|
||||||
- Add repository-backed import from configured HTTPS repository settings.
|
- Add repository-backed import from configured HTTPS repository settings.
|
||||||
- Add a selected-commit workflow to copy commit SHA/details and create a draft update entry linked to those commits.
|
- Add a selected-commit workflow to copy commit SHA/details and create a draft update entry linked to those commits.
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
<div class="shell-sidebar-wrap">
|
<div class="shell-sidebar-wrap">
|
||||||
<app-sidebar :is-expanded="isSidebarExpanded" />
|
<app-sidebar :is-expanded="isSidebarExpanded" />
|
||||||
|
|
||||||
<button
|
<v-btn
|
||||||
class="sidebar-boundary-toggle"
|
class="sidebar-boundary-toggle"
|
||||||
type="button"
|
variant="text"
|
||||||
|
:ripple="false"
|
||||||
@click="isSidebarExpanded = !isSidebarExpanded"
|
@click="isSidebarExpanded = !isSidebarExpanded"
|
||||||
>
|
>
|
||||||
<v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" />
|
<v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" />
|
||||||
</button>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -69,8 +70,8 @@
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(255, 174, 94, 0.18), transparent 28%),
|
radial-gradient(circle at top left, rgba(255, 174, 94, 0.18), transparent 28%),
|
||||||
radial-gradient(circle at top right, rgba(52, 211, 153, 0.16), transparent 24%),
|
radial-gradient(circle at top right, rgba(52, 211, 153, 0.16), transparent 24%),
|
||||||
linear-gradient(180deg, #fffaf2 0%, #f6efe2 100%);
|
linear-gradient(180deg, var(--app-color-on-primary) 0%, #f6efe2 100%);
|
||||||
color: #172033;
|
color: var(--app-color-on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell-main {
|
.shell-main {
|
||||||
@@ -86,16 +87,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-boundary-toggle {
|
.sidebar-boundary-toggle {
|
||||||
@apply absolute left-full top-[1.48rem] z-40 flex h-[1.8rem] w-[1.8rem] -translate-x-1/2 items-center justify-center rounded-full border transition-colors;
|
@apply absolute left-full top-[1.48rem] z-40 flex h-[1.8rem] min-w-0 w-[1.8rem] -translate-x-1/2 items-center justify-center rounded-full border p-0 normal-case transition-colors;
|
||||||
background: rgba(255, 250, 242, 0.98);
|
background: rgba(255, 250, 242, 0.98);
|
||||||
border-color: rgba(23, 32, 51, 0.12);
|
border-color: var(--app-border-subtle);
|
||||||
color: #44516a;
|
color: #44516a;
|
||||||
box-shadow: 0 12px 28px rgba(23, 32, 51, 0.12);
|
box-shadow: 0 12px 28px var(--app-border-subtle);
|
||||||
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-boundary-toggle:hover {
|
.sidebar-boundary-toggle:hover {
|
||||||
background: #172033;
|
background: var(--app-color-on-surface);
|
||||||
color: #fffaf2;
|
color: var(--app-color-on-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-boundary-toggle :deep(.v-icon) {
|
.sidebar-boundary-toggle :deep(.v-icon) {
|
||||||
|
|||||||
340
frontend/src/api/schema.d.ts
vendored
340
frontend/src/api/schema.d.ts
vendored
@@ -132,6 +132,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/developer/release-update-email-digests/force": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersForceDeveloperReleaseUpdateDigestEmailsHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/developer/release-updates/{id}": {
|
"/api/developer/release-updates/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -164,22 +180,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/developer/release-commits/import": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
post: operations["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/developer/release-commits": {
|
"/api/developer/release-commits": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -260,7 +260,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/developer/release-updates/{id}/send-email": {
|
"/api/developer/release-commits/refresh": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -269,7 +269,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put?: never;
|
||||||
post: operations["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler"];
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@@ -292,6 +292,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/developer/release-commits/{sha}/link-first-release": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/developer/release-commits/{sha}/unlink": {
|
"/api/developer/release-commits/{sha}/unlink": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -580,6 +596,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/users/preferred-language": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["SocializeApiModulesIdentityHandlersChangePreferredLanguageHandler"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/users/confirm-email-change": {
|
"/api/users/confirm-email-change": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1471,15 +1503,12 @@ export interface components {
|
|||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
id?: string;
|
id?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
summary?: string;
|
description?: string;
|
||||||
body?: string | null;
|
titleEn?: string;
|
||||||
category?: string;
|
descriptionEn?: string;
|
||||||
importance?: string;
|
titleFr?: string;
|
||||||
audience?: string;
|
descriptionFr?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
deploymentLabel?: string | null;
|
|
||||||
buildVersion?: string | null;
|
|
||||||
commitRange?: string | null;
|
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -1488,25 +1517,17 @@ export interface components {
|
|||||||
publishedAt?: string | null;
|
publishedAt?: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
/** Format: guid */
|
|
||||||
manualEmailSentByUserId?: string | null;
|
|
||||||
/** Format: date-time */
|
|
||||||
manualEmailSentAt?: string | null;
|
|
||||||
manualEmailAudience?: string | null;
|
|
||||||
/** Format: int32 */
|
|
||||||
manualEmailRecipientCount?: number | null;
|
|
||||||
isRead?: boolean;
|
isRead?: boolean;
|
||||||
};
|
};
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest: {
|
SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest: {
|
||||||
title: string;
|
titleEn: string;
|
||||||
summary: string;
|
descriptionEn: string;
|
||||||
body?: string | null;
|
titleFr: string;
|
||||||
category: string;
|
descriptionFr: string;
|
||||||
importance: string;
|
};
|
||||||
audience: string;
|
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto: {
|
||||||
deploymentLabel?: string | null;
|
/** Format: int32 */
|
||||||
buildVersion?: string | null;
|
sentCount?: number;
|
||||||
commitRange?: string | null;
|
|
||||||
};
|
};
|
||||||
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
|
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -1515,15 +1536,6 @@ export interface components {
|
|||||||
importantUnreadCount?: number;
|
importantUnreadCount?: number;
|
||||||
updates?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
|
updates?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
|
||||||
};
|
};
|
||||||
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto: {
|
|
||||||
/** Format: int32 */
|
|
||||||
importedCount?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
updatedCount?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
skippedCount?: number;
|
|
||||||
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
|
|
||||||
};
|
|
||||||
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto: {
|
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto: {
|
||||||
sha?: string;
|
sha?: string;
|
||||||
shortSha?: string;
|
shortSha?: string;
|
||||||
@@ -1545,52 +1557,32 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest: {
|
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto: {
|
||||||
sinceSha?: string | null;
|
|
||||||
untilSha?: string | null;
|
|
||||||
sourceBranch?: string | null;
|
|
||||||
deploymentLabel?: string | null;
|
|
||||||
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto"][] | null;
|
|
||||||
};
|
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto: {
|
|
||||||
sha?: string;
|
|
||||||
shortSha?: string | null;
|
|
||||||
subject?: string;
|
|
||||||
authorName?: string | null;
|
|
||||||
authorEmail?: string | null;
|
|
||||||
/** Format: date-time */
|
|
||||||
authoredAt?: string | null;
|
|
||||||
/** Format: date-time */
|
|
||||||
committedAt?: string | null;
|
|
||||||
sourceBranch?: string | null;
|
|
||||||
deploymentLabel?: string | null;
|
|
||||||
externalUrl?: string | null;
|
|
||||||
};
|
|
||||||
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto: {
|
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
recipientCount?: number;
|
createdCount?: number;
|
||||||
/** Format: date-time */
|
/** Format: int32 */
|
||||||
sentAt?: string;
|
updatedCount?: number;
|
||||||
testMode?: boolean;
|
/** Format: int32 */
|
||||||
};
|
skippedCount?: number;
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest: {
|
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
|
||||||
testMode?: boolean;
|
|
||||||
confirmResend?: boolean;
|
|
||||||
};
|
};
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest: {
|
SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
releaseUpdateId?: string;
|
releaseUpdateId?: string;
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto: {
|
||||||
|
/** Format: int32 */
|
||||||
|
linkedCount?: number;
|
||||||
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest: {
|
||||||
|
/** Format: guid */
|
||||||
|
releaseUpdateId?: string;
|
||||||
|
};
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest: {
|
SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest: {
|
||||||
title: string;
|
titleEn: string;
|
||||||
summary: string;
|
descriptionEn: string;
|
||||||
body?: string | null;
|
titleFr: string;
|
||||||
category: string;
|
descriptionFr: string;
|
||||||
importance: string;
|
|
||||||
audience: string;
|
|
||||||
deploymentLabel?: string | null;
|
|
||||||
buildVersion?: string | null;
|
|
||||||
commitRange?: string | null;
|
|
||||||
};
|
};
|
||||||
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
|
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
@@ -1727,6 +1719,9 @@ export interface components {
|
|||||||
/** Format: binary */
|
/** Format: binary */
|
||||||
file: string;
|
file: string;
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest: {
|
||||||
|
preferredLanguage?: string;
|
||||||
|
};
|
||||||
SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: {
|
SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: {
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
@@ -1752,6 +1747,7 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
birthDate?: string | null;
|
birthDate?: string | null;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
|
preferredLanguage?: string;
|
||||||
};
|
};
|
||||||
SystemIOStream: components["schemas"]["SystemMarshalByRefObject"] & {
|
SystemIOStream: components["schemas"]["SystemMarshalByRefObject"] & {
|
||||||
canTimeout?: boolean;
|
canTimeout?: boolean;
|
||||||
@@ -2759,6 +2755,40 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersForceDeveloperReleaseUpdateDigestEmailsHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler: {
|
SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2871,44 +2901,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
|
||||||
/** @description Success */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Unauthorized */
|
|
||||||
401: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
/** @description Forbidden */
|
|
||||||
403: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler: {
|
SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3058,20 +3050,14 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler: {
|
SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path?: never;
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody: {
|
requestBody?: never;
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Success */
|
/** @description Success */
|
||||||
200: {
|
200: {
|
||||||
@@ -3079,7 +3065,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto"];
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -3138,6 +3124,46 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
sha: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler: {
|
SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3837,6 +3863,44 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesIdentityHandlersChangePreferredLanguageHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler: {
|
SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss/theme.css";
|
||||||
|
@import "tailwindcss/utilities.css";
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -23,139 +24,110 @@ body,
|
|||||||
background: #f4f6f3;
|
background: #f4f6f3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
--app-color-background: rgb(var(--v-theme-background));
|
||||||
|
--app-color-on-background: rgb(var(--v-theme-on-background));
|
||||||
|
--app-color-surface: rgb(var(--v-theme-surface));
|
||||||
|
--app-color-surface-muted: rgb(var(--v-theme-surface-muted));
|
||||||
|
--app-color-on-surface: rgb(var(--v-theme-on-surface));
|
||||||
|
--app-color-control: rgb(var(--v-theme-control));
|
||||||
|
--app-color-control-hover: rgb(var(--v-theme-control-hover));
|
||||||
|
--app-color-control-focus: rgb(var(--v-theme-control-focus));
|
||||||
|
--app-color-border: rgb(var(--v-theme-border));
|
||||||
|
--app-color-border-strong: rgb(var(--v-theme-border-strong));
|
||||||
|
--app-color-primary: rgb(var(--v-theme-primary));
|
||||||
|
--app-color-on-primary: rgb(var(--v-theme-on-primary));
|
||||||
|
--app-color-secondary: rgb(var(--v-theme-secondary));
|
||||||
|
--app-color-on-secondary: rgb(var(--v-theme-on-secondary));
|
||||||
|
--app-color-tertiary: rgb(var(--v-theme-tertiary));
|
||||||
|
--app-color-on-tertiary: rgb(var(--v-theme-on-tertiary));
|
||||||
|
--app-color-accent: rgb(var(--v-theme-accent));
|
||||||
|
--app-color-accent-strong: rgb(var(--v-theme-accent-strong));
|
||||||
|
--app-color-highlight: rgb(var(--v-theme-highlight));
|
||||||
|
--app-color-danger: rgb(var(--v-theme-error));
|
||||||
|
--app-color-on-danger: rgb(var(--v-theme-on-error));
|
||||||
|
--app-text-muted: #526178;
|
||||||
|
--app-text-subtle: #7a8799;
|
||||||
|
--app-border-subtle: rgba(23, 32, 51, 0.08);
|
||||||
|
--app-border-muted: rgba(23, 32, 51, 0.06);
|
||||||
|
--app-surface-glass: rgba(251, 250, 246, 0.84);
|
||||||
|
--app-surface-raised: #ffffff;
|
||||||
|
--app-control-subtle: rgb(var(--v-theme-control));
|
||||||
|
--app-control-hover: rgb(var(--v-theme-control-hover));
|
||||||
|
--app-control-active: rgba(23, 32, 51, 0.1);
|
||||||
|
--app-danger-muted: rgb(var(--v-theme-error));
|
||||||
|
--app-shadow-popover: 0 18px 40px var(--app-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
input::placeholder,
|
input::placeholder,
|
||||||
textarea::placeholder {
|
textarea::placeholder {
|
||||||
color: #68778a;
|
color: #68778a;
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-application {
|
|
||||||
background: rgb(var(--v-theme-background)) !important;
|
|
||||||
color: rgb(var(--v-theme-on-background));
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-card,
|
|
||||||
.v-sheet,
|
|
||||||
.v-list,
|
|
||||||
.v-menu > .v-overlay__content,
|
|
||||||
.v-dialog > .v-overlay__content {
|
|
||||||
background-color: rgb(var(--v-theme-surface)) !important;
|
|
||||||
border: 1px solid rgb(var(--v-theme-border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-field {
|
|
||||||
background-color: rgb(var(--v-theme-control)) !important;
|
|
||||||
color: rgb(var(--v-theme-on-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-field:hover {
|
|
||||||
background-color: rgb(var(--v-theme-control-hover)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-field--focused {
|
|
||||||
background-color: rgb(var(--v-theme-control-focus)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-field__outline {
|
|
||||||
color: rgb(var(--v-theme-border-strong));
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-field--focused .v-field__outline {
|
|
||||||
color: rgb(var(--v-theme-highlight));
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-field__input,
|
|
||||||
.v-field-label {
|
|
||||||
color: rgb(var(--v-theme-on-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-select .v-field .v-field__input > input,
|
|
||||||
.v-select .v-field .v-field__input > input::placeholder {
|
|
||||||
color: transparent !important;
|
|
||||||
caret-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel,
|
|
||||||
[class$='-panel'],
|
|
||||||
[class$='-card'],
|
|
||||||
div.card {
|
|
||||||
border-color: rgb(var(--v-theme-border)) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.btn {
|
.app-sidebar .sidebar-control {
|
||||||
@apply min-w-24 w-full;
|
@apply flex min-w-0 items-center gap-3 rounded-[1.1rem] px-4 py-3 text-sm font-semibold no-underline transition-colors;
|
||||||
@apply p-4;
|
background: transparent;
|
||||||
@apply flex flex-nowrap gap-4 items-center justify-center;
|
color: #44516a;
|
||||||
@apply rounded-lg;
|
|
||||||
@apply capitalize text-base font-sans font-medium;
|
|
||||||
@apply px-10;
|
|
||||||
@apply cursor-pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary {
|
.app-sidebar .sidebar-control:hover {
|
||||||
@apply min-w-24 w-full;
|
background: var(--app-control-hover);
|
||||||
@apply p-4;
|
color: var(--app-color-on-surface);
|
||||||
@apply flex flex-nowrap gap-4 items-center justify-center;
|
|
||||||
@apply rounded-lg;
|
|
||||||
@apply capitalize text-base font-sans font-medium;
|
|
||||||
@apply px-10;
|
|
||||||
@apply cursor-pointer;
|
|
||||||
@apply bg-hPrimary text-hOnPrimary;
|
|
||||||
@apply hover:brightness-125;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button.secondary {
|
.app-sidebar .sidebar-control-active {
|
||||||
@apply min-w-24 w-full;
|
background: linear-gradient(135deg, rgba(255, 138, 61, 0.14), rgba(239, 68, 68, 0.1));
|
||||||
@apply p-4;
|
box-shadow: inset 0 0 0 1px rgba(255, 138, 61, 0.2);
|
||||||
@apply flex flex-nowrap gap-4 items-center justify-center;
|
color: var(--app-color-on-surface);
|
||||||
@apply rounded-lg;
|
|
||||||
@apply capitalize text-base font-sans font-medium;
|
|
||||||
@apply px-10;
|
|
||||||
@apply cursor-pointer;
|
|
||||||
@apply bg-hSecondary text-hOnSecondary;
|
|
||||||
@apply hover:brightness-125;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.dialog {
|
.app-sidebar .sidebar-icon-button {
|
||||||
@apply max-h-[90vh];
|
@apply flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[1rem] transition-colors no-underline;
|
||||||
@apply place-self-center;
|
background: transparent;
|
||||||
|
color: var(--app-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
div.card {
|
.app-sidebar .sidebar-icon-button:hover {
|
||||||
@apply w-full max-w-[1024px];
|
background: var(--app-control-hover);
|
||||||
@apply rounded-xl p-4;
|
color: var(--app-color-on-surface);
|
||||||
@apply flex flex-col gap-4;
|
|
||||||
@apply bg-hSurface text-hOnSurface;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Specific styling for dialog cards */
|
.app-sidebar .sidebar-menu-surface {
|
||||||
div.card.dialog {
|
@apply z-30 flex flex-col gap-1 rounded-[1.25rem] border p-2;
|
||||||
@apply bg-hSurface text-hOnSurface;
|
background: var(--app-surface-raised);
|
||||||
@apply rounded-xl;
|
border-color: var(--app-border-subtle);
|
||||||
@apply shadow-lg;
|
box-shadow: var(--app-shadow-popover);
|
||||||
}
|
}
|
||||||
|
|
||||||
div.card-title {
|
.app-sidebar .sidebar-menu-option {
|
||||||
@apply font-sans font-bold text-2xl;
|
@apply flex items-center gap-3 rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
|
||||||
@apply p-2;
|
color: var(--app-color-on-surface);
|
||||||
@apply text-hOnSurface;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.card-content {
|
.app-sidebar .sidebar-menu-option:hover {
|
||||||
@apply flex flex-col gap-4;
|
background: var(--app-control-hover);
|
||||||
@apply p-2;
|
|
||||||
@apply text-hOnSurface;
|
|
||||||
@apply overflow-y-auto max-h-[60vh];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.card-actions {
|
.app-sidebar .sidebar-menu-option .v-icon {
|
||||||
@apply p-2;
|
@apply text-base;
|
||||||
@apply flex flex-row gap-4 justify-end;
|
color: var(--app-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
div.card-actions > * {
|
.app-sidebar .sidebar-menu-option-danger {
|
||||||
@apply w-fit;
|
color: var(--app-danger-muted);
|
||||||
@apply sm:min-w-40 min-w-0;
|
}
|
||||||
|
|
||||||
|
.app-sidebar .sidebar-menu-option-danger .v-icon {
|
||||||
|
color: var(--app-danger-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar .sidebar-menu-separator {
|
||||||
|
@apply my-1;
|
||||||
|
border-top: 1px solid var(--app-border-subtle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
frontend/src/assets/styles.css
Normal file
2
frontend/src/assets/styles.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import "vuetify/styles";
|
||||||
|
@import "./main.css";
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
.avatar {
|
.avatar {
|
||||||
@apply inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-black uppercase;
|
@apply inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-black uppercase;
|
||||||
background: linear-gradient(135deg, rgba(15, 118, 110, 0.16) 0%, rgba(255, 138, 61, 0.18) 100%);
|
background: linear-gradient(135deg, rgba(15, 118, 110, 0.16) 0%, rgba(255, 138, 61, 0.18) 100%);
|
||||||
color: #172033;
|
color: var(--app-color-on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar img {
|
.avatar img {
|
||||||
|
|||||||
@@ -148,13 +148,13 @@
|
|||||||
<div class="cropper-eyebrow">Image editor</div>
|
<div class="cropper-eyebrow">Image editor</div>
|
||||||
<h2>{{ title }}</h2>
|
<h2>{{ title }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<v-btn variant="text" :ripple="false"
|
||||||
class="plain-button"
|
class="plain-button"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
@click="closeDialog"
|
@click="closeDialog"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cropper-actions">
|
<div class="cropper-actions">
|
||||||
@@ -178,42 +178,42 @@
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
hide-details
|
hide-details
|
||||||
/>
|
/>
|
||||||
<button
|
<v-btn variant="text" :ripple="false"
|
||||||
class="action-button secondary"
|
class="action-button secondary"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
@click="loadImageFromUrl"
|
@click="loadImageFromUrl"
|
||||||
>
|
>
|
||||||
{{ loadLabel }}
|
{{ loadLabel }}
|
||||||
</button>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<v-btn variant="text" :ripple="false"
|
||||||
class="action-button secondary"
|
class="action-button secondary"
|
||||||
:disabled="!isReady || isSaving"
|
:disabled="!isReady || isSaving"
|
||||||
@click="zoom(1.15)"
|
@click="zoom(1.15)"
|
||||||
>
|
>
|
||||||
Zoom in
|
Zoom in
|
||||||
</button>
|
</v-btn>
|
||||||
<button
|
<v-btn variant="text" :ripple="false"
|
||||||
class="action-button secondary"
|
class="action-button secondary"
|
||||||
:disabled="!isReady || isSaving"
|
:disabled="!isReady || isSaving"
|
||||||
@click="zoom(0.85)"
|
@click="zoom(0.85)"
|
||||||
>
|
>
|
||||||
Zoom out
|
Zoom out
|
||||||
</button>
|
</v-btn>
|
||||||
<button
|
<v-btn variant="text" :ripple="false"
|
||||||
class="action-button secondary"
|
class="action-button secondary"
|
||||||
:disabled="!isReady || isSaving"
|
:disabled="!isReady || isSaving"
|
||||||
@click="rotate(-90)"
|
@click="rotate(-90)"
|
||||||
>
|
>
|
||||||
Rotate left
|
Rotate left
|
||||||
</button>
|
</v-btn>
|
||||||
<button
|
<v-btn variant="text" :ripple="false"
|
||||||
class="action-button secondary"
|
class="action-button secondary"
|
||||||
:disabled="!isReady || isSaving"
|
:disabled="!isReady || isSaving"
|
||||||
@click="rotate(90)"
|
@click="rotate(90)"
|
||||||
>
|
>
|
||||||
Rotate right
|
Rotate right
|
||||||
</button>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -242,14 +242,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-actions">
|
<div class="footer-actions">
|
||||||
<button
|
<v-btn variant="text" :ripple="false"
|
||||||
class="action-button secondary"
|
class="action-button secondary"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
@click="closeDialog"
|
@click="closeDialog"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</v-btn>
|
||||||
<button
|
<v-btn variant="text" :ripple="false"
|
||||||
class="action-button"
|
class="action-button"
|
||||||
:disabled="!isReady || isSaving"
|
:disabled="!isReady || isSaving"
|
||||||
@click="saveCrop"
|
@click="saveCrop"
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
:width="2"
|
:width="2"
|
||||||
/>
|
/>
|
||||||
<span>{{ isSaving ? 'Saving...' : confirmLabel }}</span>
|
<span>{{ isSaving ? 'Saving...' : confirmLabel }}</span>
|
||||||
</button>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
@@ -271,8 +271,8 @@
|
|||||||
@reference "@/assets/main.css";
|
@reference "@/assets/main.css";
|
||||||
.cropper-card {
|
.cropper-card {
|
||||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: var(--app-surface-raised);
|
||||||
border-color: rgba(23, 32, 51, 0.08);
|
border-color: var(--app-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cropper-header {
|
.cropper-header {
|
||||||
@@ -281,12 +281,12 @@
|
|||||||
|
|
||||||
.cropper-eyebrow {
|
.cropper-eyebrow {
|
||||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||||
color: #0f766e;
|
color: var(--app-color-on-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cropper-header h2 {
|
.cropper-header h2 {
|
||||||
@apply mt-2 text-2xl font-black;
|
@apply mt-2 text-2xl font-black;
|
||||||
color: #172033;
|
color: var(--app-color-on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cropper-actions,
|
.cropper-actions,
|
||||||
@@ -300,9 +300,9 @@
|
|||||||
|
|
||||||
.url-input {
|
.url-input {
|
||||||
@apply min-w-0 flex-1 rounded-full border px-4 py-3 text-sm;
|
@apply min-w-0 flex-1 rounded-full border px-4 py-3 text-sm;
|
||||||
border-color: rgba(23, 32, 51, 0.12);
|
border-color: var(--app-border-subtle);
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
color: #172033;
|
color: var(--app-color-on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-actions {
|
.footer-actions {
|
||||||
@@ -315,22 +315,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-button {
|
.action-button {
|
||||||
background: #172033;
|
background: var(--app-color-on-surface);
|
||||||
color: #fffaf2;
|
color: var(--app-color-on-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button.secondary,
|
.action-button.secondary,
|
||||||
.plain-button {
|
.plain-button {
|
||||||
background: rgba(255, 255, 255, 0.84);
|
background: rgba(255, 255, 255, 0.84);
|
||||||
color: #172033;
|
color: var(--app-color-on-surface);
|
||||||
border: 1px solid rgba(23, 32, 51, 0.12);
|
border: 1px solid var(--app-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cropper-stage {
|
.cropper-stage {
|
||||||
@apply overflow-hidden rounded-[1.5rem] border;
|
@apply overflow-hidden rounded-[1.5rem] border;
|
||||||
height: 28rem;
|
height: 28rem;
|
||||||
border-color: rgba(23, 32, 51, 0.08);
|
border-color: var(--app-border-subtle);
|
||||||
background: #fffaf2;
|
background: var(--app-color-on-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state,
|
.empty-state,
|
||||||
@@ -339,14 +339,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
border-color: rgba(23, 32, 51, 0.08);
|
border-color: var(--app-border-subtle);
|
||||||
color: #526178;
|
color: var(--app-text-muted);
|
||||||
background: rgba(255, 250, 242, 0.9);
|
background: rgba(255, 250, 242, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
border-color: rgba(185, 28, 28, 0.12);
|
border-color: rgba(185, 28, 28, 0.12);
|
||||||
color: #b91c1c;
|
color: var(--app-danger-muted);
|
||||||
background: rgba(254, 226, 226, 0.75);
|
background: rgba(254, 226, 226, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import ProductFeaturePage from '@/static/views/ProductFeaturePage.vue';
|
|||||||
import PricingPage from '@/static/views/PricingPage.vue';
|
import PricingPage from '@/static/views/PricingPage.vue';
|
||||||
import BlogsPage from '@/static/views/BlogsPage.vue';
|
import BlogsPage from '@/static/views/BlogsPage.vue';
|
||||||
import GuidesPage from '@/static/views/GuidesPage.vue';
|
import GuidesPage from '@/static/views/GuidesPage.vue';
|
||||||
import './assets/main.css';
|
import { createSocializeVuetify } from '@/plugins/vuetify.js';
|
||||||
|
import './assets/styles.css';
|
||||||
|
|
||||||
const publicRoutes = [
|
const publicRoutes = [
|
||||||
{ path: '/', component: Landing },
|
{ path: '/', component: Landing },
|
||||||
@@ -45,6 +46,7 @@ export async function render(routePath) {
|
|||||||
render: () => h(RouterView),
|
render: () => h(RouterView),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use(createSocializeVuetify());
|
||||||
app.use(createPinia());
|
app.use(createPinia());
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(head);
|
app.use(head);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user