1 Commits

Author SHA1 Message Date
0fbb30bb4f feat: add google drive dam foundation 2026-05-08 11:36:30 -04:00
176 changed files with 4373 additions and 16695 deletions

View File

@@ -82,41 +82,6 @@ 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

View File

@@ -1,6 +1,5 @@
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;
@@ -21,10 +20,7 @@ internal static class ApplicationRegistration
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddHealthChecks() services.AddHealthChecks()
.AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy(), tags: ["live"]) .AddDbContextCheck<AppDbContext>();
.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>();

View File

@@ -1,7 +1,6 @@
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;
@@ -9,8 +8,7 @@ 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;
@@ -33,51 +31,32 @@ internal sealed class LocalBlobStorage(
string contentType, string contentType,
CancellationToken ct = default) CancellationToken ct = default)
{ {
try stream.Position = 0;
if (stream.Length > MaxUploadSize)
{ {
stream.Position = 0; 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 (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)
if (!ContentTypes.IsAllowed(contentType, stream))
{ {
metrics.RecordBlobStorageOperation("upload", false); logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType);
throw; throw new InvalidOperationException("Unsupported file type.");
}
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(
@@ -85,43 +64,19 @@ internal sealed class LocalBlobStorage(
string blobName, string blobName,
CancellationToken ct = default) CancellationToken ct = default)
{ {
try string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
{
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
if (!File.Exists(filePath)) if (!File.Exists(filePath))
{ {
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName); throw new FileNotFoundException("Blob storage: Local file was not found.", blobName);
} }
MemoryStream memoryStream = new(); MemoryStream memoryStream = new();
await using FileStream fileStream = File.OpenRead(filePath); await using FileStream fileStream = File.OpenRead(filePath);
await fileStream.CopyToAsync(memoryStream, ct); await fileStream.CopyToAsync(memoryStream, ct);
memoryStream.Position = 0; memoryStream.Position = 0;
metrics.RecordBlobStorageOperation("download", true);
return memoryStream; 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;
}
} }
internal string GetRootPath() internal string GetRootPath()

View File

@@ -1,11 +1,8 @@
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( internal class LoggerEmailSender(ILogger<IEmailSender> logger)
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 =
@@ -17,7 +14,6 @@ internal class LoggerEmailSender(
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;
} }

View File

@@ -3,7 +3,6 @@ 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;
@@ -12,16 +11,13 @@ 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);
@@ -53,33 +49,13 @@ 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");
try using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
{
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
if (!response.IsSuccessStatusCode) 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); string body = await response.Content.ReadAsStringAsync();
throw; throw new InvalidOperationException(
} $"Resend email failed: {response.StatusCode} - {body}");
catch (TaskCanceledException)
{
_metrics.RecordEmailDelivery("resend", false);
throw;
}
catch (InvalidOperationException)
{
_metrics.RecordEmailDelivery("resend", false);
throw;
} }
} }

View File

@@ -1,29 +0,0 @@
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."));
}
}

View File

@@ -1,40 +0,0 @@
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);
}
}
}

View File

@@ -1,162 +0,0 @@
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);
}
}

View File

@@ -1,61 +0,0 @@
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;
}
}

View File

@@ -1,258 +0,0 @@
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);
}

View File

@@ -1,102 +0,0 @@
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
}
}

View File

@@ -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 user.GetWorkspaceScopeIds().Contains(workspaceId); return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId);
} }
public static bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId) public static bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
@@ -34,25 +34,24 @@ 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 CanAccessWorkspace(user, workspaceId) && return IsManager(user)
(IsManager(user) || user.GetClientScopeIds().Contains(clientId)); || (CanAccessWorkspace(user, workspaceId) && 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 CanAccessClient(user, workspaceId, clientId) && return IsManager(user)
(IsManager(user) || user.GetCampaignScopeIds().Contains(campaignId)); || (CanAccessClient(user, workspaceId, clientId) && 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) && CanAccessCampaign(user, workspaceId, clientId, campaignId) return IsManager(user) || (IsProvider(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) && CanAccessCampaign(user, workspaceId, clientId, campaignId) return IsManager(user)
|| 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);
} }
@@ -69,7 +68,7 @@ internal sealed class AccessScopeService(
Guid workspaceId, Guid workspaceId,
CancellationToken ct) CancellationToken ct)
{ {
return user.GetWorkspaceScopeIds().Contains(workspaceId) return CanAccessWorkspace(user, workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync( || await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,
@@ -82,7 +81,7 @@ internal sealed class AccessScopeService(
Guid workspaceId, Guid workspaceId,
CancellationToken ct) CancellationToken ct)
{ {
return CanManageWorkspace(user, workspaceId) return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync( || await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,
@@ -95,7 +94,8 @@ internal sealed class AccessScopeService(
Guid organizationId, Guid organizationId,
CancellationToken ct) CancellationToken ct)
{ {
return await organizationAccessService.HasOrganizationPermissionAsync( return IsManager(user)
|| await organizationAccessService.HasOrganizationPermissionAsync(
user, user,
organizationId, organizationId,
OrganizationPermissions.CreateWorkspaces, OrganizationPermissions.CreateWorkspaces,
@@ -108,7 +108,8 @@ internal sealed class AccessScopeService(
Guid clientId, Guid clientId,
CancellationToken ct) CancellationToken ct)
{ {
if (await organizationAccessService.HasInheritedWorkspacePermissionAsync( if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces, OrganizationPermissions.AccessOwnedWorkspaces,
@@ -127,7 +128,8 @@ internal sealed class AccessScopeService(
Guid campaignId, Guid campaignId,
CancellationToken ct) CancellationToken ct)
{ {
if (await organizationAccessService.HasInheritedWorkspacePermissionAsync( if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces, OrganizationPermissions.AccessOwnedWorkspaces,
@@ -147,7 +149,7 @@ internal sealed class AccessScopeService(
Guid campaignId, Guid campaignId,
CancellationToken ct) CancellationToken ct)
{ {
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct) return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync( || await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,
@@ -163,7 +165,7 @@ internal sealed class AccessScopeService(
Guid campaignId, Guid campaignId,
CancellationToken ct) CancellationToken ct)
{ {
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct) return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync( || await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,

View File

@@ -15,6 +15,7 @@ using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data; using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services; using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using Socialize.Api.Modules.Workspaces.Services;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Socialize.Api.Infrastructure.TestData; namespace Socialize.Api.Infrastructure.TestData;
@@ -261,6 +262,10 @@ internal static class TestDataSeedExtensions
} }
organization.Name = "Northstar Agency"; organization.Name = "Northstar Agency";
organization.IsGoogleDriveDamEnabled = true;
organization.GoogleDriveRootFolderId = "dev-socialize-dam-root";
organization.GoogleDriveRootFolderName = "Socialize DAM";
organization.GoogleDriveRootFolderUrl = "https://drive.google.com/drive/folders/dev-socialize-dam-root";
organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId; organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId;
organization.OwnerUserId = managerUserId; organization.OwnerUserId = managerUserId;
@@ -465,6 +470,7 @@ internal static class TestDataSeedExtensions
asset.DisplayName = "Spring launch cut"; asset.DisplayName = "Spring launch cut";
asset.GoogleDriveFileId = "dev-socialize-demo"; asset.GoogleDriveFileId = "dev-socialize-demo";
asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view"; asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view";
asset.GoogleDriveWorkspaceFolderPath = "Socialize DAM/luma-coffee";
asset.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo"; asset.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo";
asset.CurrentRevisionNumber = 2; asset.CurrentRevisionNumber = 2;
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
@@ -587,6 +593,7 @@ internal static class TestDataSeedExtensions
{ {
Id = id, Id = id,
Name = string.Empty, Name = string.Empty,
Slug = string.Empty,
TimeZone = string.Empty, TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
@@ -594,6 +601,12 @@ internal static class TestDataSeedExtensions
} }
workspace.Name = name; workspace.Name = name;
workspace.Slug = await WorkspaceSlugGenerator.CreateUniqueAsync(
dbContext,
organizationId,
string.IsNullOrWhiteSpace(workspace.Slug) ? name : workspace.Slug,
workspace.Id,
cancellationToken);
workspace.OrganizationId = organizationId; workspace.OrganizationId = organizationId;
workspace.OwnerUserId = ownerUserId; workspace.OwnerUserId = ownerUserId;
workspace.TimeZone = timeZone; workspace.TimeZone = timeZone;

View File

@@ -1,118 +0,0 @@
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");
}
}
}

View File

@@ -1,49 +0,0 @@
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");
}
}
}

View File

@@ -1,53 +0,0 @@
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);
}
}
}

View File

@@ -1,62 +0,0 @@
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);
}
}
}

View File

@@ -1,34 +0,0 @@
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");
}
}
}

View File

@@ -12,8 +12,8 @@ using Socialize.Api.Data;
namespace Socialize.Api.Migrations namespace Socialize.Api.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260508031114_AddFrenchReleaseUpdateFields")] [Migration("20260508152102_AddGoogleDriveDamFoundation")]
partial class AddFrenchReleaseUpdateFields partial class AddGoogleDriveDamFoundation
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -377,6 +377,10 @@ namespace Socialize.Api.Migrations
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b.Property<string>("GoogleDriveWorkspaceFolderPath")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PreviewUrl") b.Property<string>("PreviewUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
@@ -1661,6 +1665,23 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("GoogleDriveRootFolderId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<bool>("IsGoogleDriveDamEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl") b.Property<string>("LogoUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
@@ -1983,6 +2004,28 @@ 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")
@@ -1991,6 +2034,19 @@ 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") b.Property<int?>("ManualEmailRecipientCount")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -2013,26 +2069,18 @@ namespace Socialize.Api.Migrations
.HasMaxLength(512) .HasMaxLength(512)
.HasColumnType("character varying(512)"); .HasColumnType("character varying(512)");
b.Property<string>("SummaryFr")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
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");
@@ -2143,6 +2191,11 @@ namespace Socialize.Api.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasDefaultValue(false); .HasDefaultValue(false);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("character varying(96)");
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
@@ -2154,6 +2207,9 @@ namespace Socialize.Api.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.HasIndex("OrganizationId", "Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null); b.ToTable("Workspaces", (string)null);
}); });

View File

@@ -0,0 +1,139 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddGoogleDriveDamFoundation : Migration
{
private static readonly string[] WorkspaceOrganizationSlugIndexColumns =
[
"OrganizationId",
"Slug",
];
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<string>(
name: "Slug",
table: "Workspaces",
type: "character varying(96)",
maxLength: 96,
nullable: false,
defaultValue: "");
migrationBuilder.Sql(
"""
WITH normalized AS (
SELECT
"Id",
"OrganizationId",
COALESCE(
NULLIF(
trim(both '-' from lower(regexp_replace(trim("Name"), '[^a-zA-Z0-9]+', '-', 'g'))),
''
),
'workspace'
) AS "BaseSlug"
FROM "Workspaces"
),
numbered AS (
SELECT
"Id",
"BaseSlug",
row_number() OVER (PARTITION BY "OrganizationId", "BaseSlug" ORDER BY "CreatedAt", "Id") AS "SlugIndex"
FROM normalized
)
UPDATE "Workspaces"
SET "Slug" = left(
CASE
WHEN numbered."SlugIndex" = 1 THEN numbered."BaseSlug"
ELSE numbered."BaseSlug" || '-' || numbered."SlugIndex"
END,
96
)
FROM numbered
WHERE "Workspaces"."Id" = numbered."Id";
""");
migrationBuilder.AddColumn<string>(
name: "GoogleDriveRootFolderId",
table: "Organizations",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GoogleDriveRootFolderName",
table: "Organizations",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GoogleDriveRootFolderUrl",
table: "Organizations",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsGoogleDriveDamEnabled",
table: "Organizations",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "GoogleDriveWorkspaceFolderPath",
table: "Assets",
type: "character varying(512)",
maxLength: 512,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Workspaces_OrganizationId_Slug",
table: "Workspaces",
columns: WorkspaceOrganizationSlugIndexColumns,
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropIndex(
name: "IX_Workspaces_OrganizationId_Slug",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "Slug",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "GoogleDriveRootFolderId",
table: "Organizations");
migrationBuilder.DropColumn(
name: "GoogleDriveRootFolderName",
table: "Organizations");
migrationBuilder.DropColumn(
name: "GoogleDriveRootFolderUrl",
table: "Organizations");
migrationBuilder.DropColumn(
name: "IsGoogleDriveDamEnabled",
table: "Organizations");
migrationBuilder.DropColumn(
name: "GoogleDriveWorkspaceFolderPath",
table: "Assets");
}
}
}

View File

@@ -374,6 +374,10 @@ namespace Socialize.Api.Migrations
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b.Property<string>("GoogleDriveWorkspaceFolderPath")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PreviewUrl") b.Property<string>("PreviewUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
@@ -1556,11 +1560,6 @@ 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)");
@@ -1663,6 +1662,23 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("GoogleDriveRootFolderId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<bool>("IsGoogleDriveDamEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl") b.Property<string>("LogoUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
@@ -1985,6 +2001,28 @@ 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")
@@ -1993,6 +2031,28 @@ 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");
@@ -2003,29 +2063,21 @@ namespace Socialize.Api.Migrations
b.Property<string>("Summary") b.Property<string>("Summary")
.IsRequired() .IsRequired()
.HasMaxLength(4000) .HasMaxLength(512)
.HasColumnType("character varying(4000)"); .HasColumnType("character varying(512)");
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");
@@ -2136,6 +2188,11 @@ namespace Socialize.Api.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasDefaultValue(false); .HasDefaultValue(false);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("character varying(96)");
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
@@ -2147,6 +2204,9 @@ namespace Socialize.Api.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.HasIndex("OrganizationId", "Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null); b.ToTable("Workspaces", (string)null);
}); });

View File

@@ -1,7 +1,6 @@
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;
@@ -38,8 +37,7 @@ 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()
@@ -159,7 +157,6 @@ 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)

View File

@@ -10,6 +10,7 @@ internal class Asset
public required string DisplayName { get; set; } public required string DisplayName { get; set; }
public string? GoogleDriveFileId { get; set; } public string? GoogleDriveFileId { get; set; }
public string? GoogleDriveLink { get; set; } public string? GoogleDriveLink { get; set; }
public string? GoogleDriveWorkspaceFolderPath { get; set; }
public string? PreviewUrl { get; set; } public string? PreviewUrl { get; set; }
public int CurrentRevisionNumber { get; set; } public int CurrentRevisionNumber { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }

View File

@@ -17,6 +17,7 @@ internal static class AssetModelConfiguration
asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired(); asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256); asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256);
asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048); asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048);
asset.Property(x => x.GoogleDriveWorkspaceFolderPath).HasMaxLength(512);
asset.Property(x => x.PreviewUrl).HasMaxLength(2048); asset.Property(x => x.PreviewUrl).HasMaxLength(2048);
asset.Property(x => x.CreatedAt) asset.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()

View File

@@ -6,6 +6,8 @@ using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Contracts; using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data;
using System.Text.Json; using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers; namespace Socialize.Api.Modules.Assets.Handlers;
@@ -67,6 +69,26 @@ internal class CreateGoogleDriveAssetHandler(
return; return;
} }
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == workspace.OrganizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
string? workspaceFolderPath = organization.IsGoogleDriveDamEnabled
? $"{organization.GoogleDriveRootFolderName}/{workspace.Slug}"
: null;
Asset asset = new() Asset asset = new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@@ -77,6 +99,7 @@ internal class CreateGoogleDriveAssetHandler(
DisplayName = request.DisplayName.Trim(), DisplayName = request.DisplayName.Trim(),
GoogleDriveFileId = request.GoogleDriveFileId.Trim(), GoogleDriveFileId = request.GoogleDriveFileId.Trim(),
GoogleDriveLink = request.GoogleDriveLink.Trim(), GoogleDriveLink = request.GoogleDriveLink.Trim(),
GoogleDriveWorkspaceFolderPath = workspaceFolderPath,
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(), PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
CurrentRevisionNumber = 1, CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
@@ -111,6 +134,7 @@ internal class CreateGoogleDriveAssetHandler(
assetType = asset.AssetType, assetType = asset.AssetType,
sourceType = asset.SourceType, sourceType = asset.SourceType,
googleDriveFileId = asset.GoogleDriveFileId, googleDriveFileId = asset.GoogleDriveFileId,
googleDriveWorkspaceFolderPath = asset.GoogleDriveWorkspaceFolderPath,
currentRevisionNumber = asset.CurrentRevisionNumber, currentRevisionNumber = asset.CurrentRevisionNumber,
})), })),
ct); ct);
@@ -137,6 +161,7 @@ internal class CreateGoogleDriveAssetHandler(
asset.DisplayName, asset.DisplayName,
asset.GoogleDriveFileId, asset.GoogleDriveFileId,
asset.GoogleDriveLink, asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl, asset.PreviewUrl,
asset.CurrentRevisionNumber, asset.CurrentRevisionNumber,
asset.CreatedAt, asset.CreatedAt,

View File

@@ -26,6 +26,7 @@ internal record AssetDto(
string DisplayName, string DisplayName,
string? GoogleDriveFileId, string? GoogleDriveFileId,
string? GoogleDriveLink, string? GoogleDriveLink,
string? GoogleDriveWorkspaceFolderPath,
string? PreviewUrl, string? PreviewUrl,
int CurrentRevisionNumber, int CurrentRevisionNumber,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
@@ -70,6 +71,7 @@ internal class GetAssetsHandler(
asset.DisplayName, asset.DisplayName,
asset.GoogleDriveFileId, asset.GoogleDriveFileId,
asset.GoogleDriveLink, asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl, asset.PreviewUrl,
asset.CurrentRevisionNumber, asset.CurrentRevisionNumber,
asset.CreatedAt, asset.CreatedAt,

View File

@@ -0,0 +1,123 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Assets.Handlers;
internal record WorkspaceDamBackingStoreDto(
string Type,
bool IsConfigured,
string? RootFolderId,
string? RootFolderName,
string? RootFolderUrl);
internal record WorkspaceDamFolderDto(
string Name,
string Path);
internal record WorkspaceDamDto(
Guid WorkspaceId,
Guid OrganizationId,
string WorkspaceName,
string WorkspaceSlug,
WorkspaceDamBackingStoreDto BackingStore,
WorkspaceDamFolderDto? Folder,
IReadOnlyCollection<AssetDto> Assets);
internal class GetWorkspaceDamHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<WorkspaceDamDto>
{
public override void Configure()
{
Get("/api/workspaces/{workspaceId:guid}/dam");
Options(o => o.WithTags("Assets"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid workspaceId = Route<Guid>("workspaceId");
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
IReadOnlyCollection<Guid> accessibleWorkspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
if (!accessibleWorkspaceIds.Contains(workspace.Id))
{
await SendForbiddenAsync(ct);
return;
}
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == workspace.OrganizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
WorkspaceDamBackingStoreDto backingStore = new(
organization.IsGoogleDriveDamEnabled ? "GoogleDrive" : "Unconfigured",
organization.IsGoogleDriveDamEnabled,
organization.GoogleDriveRootFolderId,
organization.GoogleDriveRootFolderName,
organization.GoogleDriveRootFolderUrl);
WorkspaceDamFolderDto? folder = organization.IsGoogleDriveDamEnabled
? new WorkspaceDamFolderDto(
workspace.Slug,
$"{organization.GoogleDriveRootFolderName}/{workspace.Slug}")
: null;
List<AssetDto> assets = await dbContext.Assets
.Where(asset => asset.WorkspaceId == workspace.Id)
.OrderBy(asset => asset.DisplayName)
.Select(asset => new AssetDto(
asset.Id,
asset.WorkspaceId,
asset.ContentItemId,
asset.AssetType,
asset.SourceType,
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,
dbContext.AssetRevisions
.Where(revision => revision.AssetId == asset.Id)
.OrderByDescending(revision => revision.RevisionNumber)
.Select(revision => new AssetRevisionDto(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt))
.ToList()))
.ToListAsync(ct);
await SendOkAsync(
new WorkspaceDamDto(
workspace.Id,
workspace.OrganizationId,
workspace.Name,
workspace.Slug,
backingStore,
folder,
assets),
ct);
}
}

View File

@@ -1,10 +1,7 @@
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
{ {
@@ -25,7 +22,6 @@ 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)
{ {
@@ -34,7 +30,6 @@ 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

View File

@@ -34,20 +34,23 @@ internal class GetCampaignsHandler(
{ {
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable(); IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); if (!AccessScopeService.IsManager(User))
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)
{ {
query = query.Where(campaign => clientScopeIds.Contains(campaign.ClientId)); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
} IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
if (!AccessScopeService.IsManager(User) && campaignScopeIds.Count > 0) query = query.Where(campaign => workspaceScopeIds.Contains(campaign.WorkspaceId));
{
query = query.Where(campaign => campaignScopeIds.Contains(campaign.Id)); if (clientScopeIds.Count > 0)
{
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)

View File

@@ -23,8 +23,11 @@ internal class GetChannelsHandler(
{ {
IQueryable<Channel> query = dbContext.Channels.AsQueryable(); IQueryable<Channel> query = dbContext.Channels.AsQueryable();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); if (!AccessScopeService.IsManager(User))
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)
{ {

View File

@@ -33,14 +33,18 @@ internal class GetClientsHandler(
{ {
IQueryable<Client> query = dbContext.Clients.AsQueryable(); IQueryable<Client> query = dbContext.Clients.AsQueryable();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); if (!AccessScopeService.IsManager(User))
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
if (!AccessScopeService.IsManager(User) && clientScopeIds.Count > 0)
{ {
query = query.Where(client => clientScopeIds.Contains(client.Id)); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
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)

View File

@@ -2,7 +2,6 @@ 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;
@@ -35,8 +34,7 @@ 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()
@@ -158,7 +156,6 @@ 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)

View File

@@ -1,7 +1,6 @@
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;
@@ -40,8 +39,7 @@ 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()
@@ -125,7 +123,6 @@ 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(

View File

@@ -37,20 +37,23 @@ internal class GetContentItemsHandler(
{ {
IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable(); IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); if (!AccessScopeService.IsManager(User))
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)
{ {
query = query.Where(item => clientScopeIds.Contains(item.ClientId)); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
} IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
if (!AccessScopeService.IsManager(User) && campaignScopeIds.Count > 0) query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
{
query = query.Where(item => campaignScopeIds.Contains(item.CampaignId)); if (clientScopeIds.Count > 0)
{
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)

View File

@@ -1,6 +1,5 @@
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;
@@ -46,8 +45,7 @@ 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()
@@ -95,7 +93,6 @@ 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);
} }

View File

@@ -37,8 +37,7 @@ 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;

View File

@@ -13,7 +13,6 @@ 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; }

View File

@@ -1,57 +0,0 @@
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);
}
}
}

View File

@@ -74,7 +74,6 @@ 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);

View File

@@ -1,6 +1,5 @@
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;
@@ -22,8 +21,7 @@ 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()
@@ -42,7 +40,6 @@ 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,
@@ -54,7 +51,6 @@ 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,
@@ -65,7 +61,6 @@ 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,
@@ -81,7 +76,6 @@ 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),

View File

@@ -17,5 +17,4 @@ 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";
} }

View File

@@ -12,5 +12,4 @@ 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";
} }

View File

@@ -56,10 +56,13 @@ internal class GetNotificationsHandler(
IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable(); IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable();
Guid currentUserId = User.GetUserId(); Guid currentUserId = User.GetUserId();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); if (!AccessScopeService.IsManager(User))
query = query.Where(notificationEvent => {
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) || IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
notificationEvent.RecipientUserId == currentUserId); query = query.Where(notificationEvent =>
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
notificationEvent.RecipientUserId == currentUserId);
}
query = query.Where(notificationEvent => query = query.Where(notificationEvent =>
notificationEvent.RecipientUserId == null || notificationEvent.RecipientUserId == null ||

View File

@@ -5,6 +5,10 @@ internal class Organization
public Guid Id { get; init; } public Guid Id { get; init; }
public required string Name { get; set; } public required string Name { get; set; }
public string? LogoUrl { get; set; } public string? LogoUrl { get; set; }
public bool IsGoogleDriveDamEnabled { get; set; }
public string? GoogleDriveRootFolderId { get; set; }
public string? GoogleDriveRootFolderName { get; set; }
public string? GoogleDriveRootFolderUrl { get; set; }
public Guid MembershipTierId { get; set; } = OrganizationMembershipTierSeed.FreeId; public Guid MembershipTierId { get; set; } = OrganizationMembershipTierSeed.FreeId;
public Guid OwnerUserId { get; set; } public Guid OwnerUserId { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }

View File

@@ -12,6 +12,10 @@ internal static class OrganizationModelConfiguration
organization.HasKey(x => x.Id); organization.HasKey(x => x.Id);
organization.Property(x => x.Name).HasMaxLength(256).IsRequired(); organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
organization.Property(x => x.LogoUrl).HasMaxLength(2048); organization.Property(x => x.LogoUrl).HasMaxLength(2048);
organization.Property(x => x.IsGoogleDriveDamEnabled).HasDefaultValue(false);
organization.Property(x => x.GoogleDriveRootFolderId).HasMaxLength(256);
organization.Property(x => x.GoogleDriveRootFolderName).HasMaxLength(256);
organization.Property(x => x.GoogleDriveRootFolderUrl).HasMaxLength(2048);
organization.Property(x => x.MembershipTierId) organization.Property(x => x.MembershipTierId)
.HasDefaultValue(OrganizationMembershipTierSeed.FreeId); .HasDefaultValue(OrganizationMembershipTierSeed.FreeId);
organization.Property(x => x.CreatedAt) organization.Property(x => x.CreatedAt)

View File

@@ -1,7 +1,6 @@
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;
@@ -22,8 +21,7 @@ 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()
@@ -68,7 +66,6 @@ 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(

View File

@@ -16,6 +16,7 @@ internal record OrganizationDto(
Guid Id, Guid Id,
string Name, string Name,
string? LogoUrl, string? LogoUrl,
OrganizationGoogleDriveDamConfigurationDto GoogleDriveDam,
OrganizationMembershipTierDto? MembershipTier, OrganizationMembershipTierDto? MembershipTier,
Guid OwnerUserId, Guid OwnerUserId,
IReadOnlyCollection<string> CurrentUserPermissions, IReadOnlyCollection<string> CurrentUserPermissions,
@@ -38,6 +39,7 @@ internal record OrganizationDto(
organization.Id, organization.Id,
organization.Name, organization.Name,
organization.LogoUrl, organization.LogoUrl,
OrganizationGoogleDriveDamConfigurationDto.FromOrganization(organization),
membershipTier, membershipTier,
organization.OwnerUserId, organization.OwnerUserId,
currentUserPermissions, currentUserPermissions,
@@ -49,6 +51,22 @@ internal record OrganizationDto(
} }
} }
internal record OrganizationGoogleDriveDamConfigurationDto(
bool IsEnabled,
string? RootFolderId,
string? RootFolderName,
string? RootFolderUrl)
{
public static OrganizationGoogleDriveDamConfigurationDto FromOrganization(Organization organization)
{
return new OrganizationGoogleDriveDamConfigurationDto(
organization.IsGoogleDriveDamEnabled,
organization.GoogleDriveRootFolderId,
organization.GoogleDriveRootFolderName,
organization.GoogleDriveRootFolderUrl);
}
}
internal record OrganizationMembershipTierDto( internal record OrganizationMembershipTierDto(
Guid Id, Guid Id,
string Key, string Key,

View File

@@ -0,0 +1,87 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.Organizations.Handlers;
internal record UpdateGoogleDriveDamConfigurationRequest(
bool IsEnabled,
string? RootFolderId,
string? RootFolderName,
string? RootFolderUrl);
internal class UpdateGoogleDriveDamConfigurationRequestValidator
: Validator<UpdateGoogleDriveDamConfigurationRequest>
{
public UpdateGoogleDriveDamConfigurationRequestValidator()
{
When(x => x.IsEnabled, () =>
{
RuleFor(x => x.RootFolderId).NotEmpty().MaximumLength(256);
RuleFor(x => x.RootFolderName).NotEmpty().MaximumLength(256);
RuleFor(x => x.RootFolderUrl).NotEmpty().MaximumLength(2048);
});
RuleFor(x => x.RootFolderId).MaximumLength(256);
RuleFor(x => x.RootFolderName).MaximumLength(256);
RuleFor(x => x.RootFolderUrl).MaximumLength(2048);
}
}
internal class UpdateGoogleDriveDamConfigurationHandler(
AppDbContext dbContext,
OrganizationAccessService organizationAccessService)
: Endpoint<UpdateGoogleDriveDamConfigurationRequest, OrganizationGoogleDriveDamConfigurationDto>
{
public override void Configure()
{
Put("/api/organizations/{organizationId:guid}/google-drive-dam");
Options(o => o.WithTags("Organizations"));
}
public override async Task HandleAsync(UpdateGoogleDriveDamConfigurationRequest request, CancellationToken ct)
{
Guid organizationId = Route<Guid>("organizationId");
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await organizationAccessService.HasOrganizationPermissionAsync(
User,
organizationId,
OrganizationPermissions.ManageConnectors,
ct))
{
await SendForbiddenAsync(ct);
return;
}
organization.IsGoogleDriveDamEnabled = request.IsEnabled;
organization.GoogleDriveRootFolderId = NormalizeOptional(request.RootFolderId);
organization.GoogleDriveRootFolderName = NormalizeOptional(request.RootFolderName);
organization.GoogleDriveRootFolderUrl = NormalizeOptional(request.RootFolderUrl);
if (!organization.IsGoogleDriveDamEnabled)
{
organization.GoogleDriveRootFolderId = null;
organization.GoogleDriveRootFolderName = null;
organization.GoogleDriveRootFolderUrl = null;
}
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(OrganizationGoogleDriveDamConfigurationDto.FromOrganization(organization), ct);
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@@ -2,16 +2,29 @@ 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
@@ -34,6 +47,13 @@ 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);
@@ -48,6 +68,11 @@ 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(
@@ -64,6 +89,11 @@ 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(
@@ -87,6 +117,11 @@ 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(
@@ -115,6 +150,11 @@ 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)

View File

@@ -5,16 +5,23 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Contracts;
internal record ReleaseUpdateDto( internal record ReleaseUpdateDto(
Guid Id, Guid Id,
string Title, string Title,
string Description, string Summary,
string TitleEn, string? Body,
string DescriptionEn, string Category,
string TitleFr, string Importance,
string DescriptionFr, string Audience,
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(
@@ -33,21 +40,22 @@ internal record ReleaseCommitDto(
DateTimeOffset ImportedAt, DateTimeOffset ImportedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
internal record ReleaseCommitRefreshResultDto( internal record ReleaseCommitImportResultDto(
int CreatedCount, int ImportedCount,
int UpdatedCount, int UpdatedCount,
int SkippedCount, int SkippedCount,
IReadOnlyCollection<ReleaseCommitDto> Commits); IReadOnlyCollection<ReleaseCommitDto> Commits);
internal record ReleaseCommitBulkLinkResultDto(int LinkedCount); internal record ReleaseUpdateEmailSendResultDto(
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)
@@ -56,15 +64,22 @@ internal static class ReleaseUpdateDtoMapper
update.Id, update.Id,
update.Title, update.Title,
update.Summary, update.Summary,
update.Title, update.Body,
update.Summary, ToDisplayString(update.Category),
update.TitleFr, update.Importance.ToString(),
update.SummaryFr, update.Audience.ToString(),
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);
} }
@@ -87,4 +102,8 @@ internal static class ReleaseUpdateDtoMapper
commit.UpdatedAt); commit.UpdatedAt);
} }
private static string ToDisplayString(ReleaseUpdateCategory category)
{
return category == ReleaseUpdateCategory.BreakingChange ? "Breaking Change" : category.ToString();
}
} }

View File

@@ -11,12 +11,19 @@ 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(4000).IsRequired(); releaseUpdate.Property(x => x.Summary).HasMaxLength(512).IsRequired();
releaseUpdate.Property(x => x.TitleFr).HasMaxLength(160).IsRequired(); releaseUpdate.Property(x => x.Body).HasMaxLength(8000);
releaseUpdate.Property(x => x.SummaryFr).HasMaxLength(4000).IsRequired(); releaseUpdate.Property(x => x.Category).HasConversion<string>().HasMaxLength(32).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);
}); });

View File

@@ -5,13 +5,22 @@ 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 TitleFr { get; set; } = string.Empty; public string? Body { get; set; }
public string SummaryFr { get; set; } = string.Empty; public ReleaseUpdateCategory Category { get; set; }
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>();
} }

View File

@@ -0,0 +1,8 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseUpdateAudience
{
Everyone,
OrganizationOwners,
Developers,
}

View File

@@ -0,0 +1,9 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseUpdateCategory
{
Feature,
Improvement,
Fix,
BreakingChange,
}

View File

@@ -0,0 +1,7 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseUpdateImportance
{
Normal,
Important,
}

View File

@@ -4,24 +4,35 @@ 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 TitleEn, string Title,
string DescriptionEn, string Summary,
string TitleFr, string? Body,
string DescriptionFr); string Category,
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.TitleEn).NotEmpty().MaximumLength(160); RuleFor(x => x.Title).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000); RuleFor(x => x.Summary).NotEmpty().MaximumLength(512);
RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160); RuleFor(x => x.Body).MaximumLength(8000);
RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000); RuleFor(x => x.Category).NotEmpty().MaximumLength(32);
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);
} }
} }
@@ -37,15 +48,26 @@ 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.TitleEn.Trim(), Title = request.Title.Trim(),
Summary = request.DescriptionEn.Trim(), Summary = request.Summary.Trim(),
TitleFr = request.TitleFr.Trim(), Body = NormalizeOptional(request.Body),
SummaryFr = request.DescriptionFr.Trim(), Category = category,
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,
@@ -56,4 +78,38 @@ 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;
}
} }

View File

@@ -1,28 +0,0 @@
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);
}
}

View File

@@ -20,9 +20,11 @@ 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
.VisibleToUsers() .VisibleTo(audienceContext)
.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))
@@ -33,7 +35,7 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
await SendOkAsync( await SendOkAsync(
new ReleaseUpdateUnreadSummaryDto( new ReleaseUpdateUnreadSummaryDto(
unreadUpdates.Count, unreadUpdates.Count,
0, unreadUpdates.Count(update => update.Importance == ReleaseUpdateImportance.Important),
unreadUpdates.Select(update => update.ToDto(false)).ToArray()), unreadUpdates.Select(update => update.ToDto(false)).ToArray()),
ct); ct);
} }

View File

@@ -0,0 +1,156 @@
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();
}
}

View File

@@ -20,9 +20,11 @@ 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
.VisibleToUsers() .VisibleTo(audienceContext)
.OrderByDescending(update => update.PublishedAt) .OrderByDescending(update => update.PublishedAt)
.ThenByDescending(update => update.CreatedAt) .ThenByDescending(update => update.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);

View File

@@ -19,9 +19,11 @@ 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
.VisibleToUsers() .VisibleTo(audienceContext)
.Select(update => update.Id) .Select(update => update.Id)
.ToListAsync(ct); .ToListAsync(ct);

View File

@@ -20,9 +20,11 @@ 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
.VisibleToUsers() .VisibleTo(audienceContext)
.AnyAsync(update => update.Id == id, ct); .AnyAsync(update => update.Id == id, ct);
if (!canReadUpdate) if (!canReadUpdate)

View File

@@ -1,95 +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 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);
}
}

View File

@@ -0,0 +1,55 @@
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);
}
}
}

View File

@@ -9,8 +9,6 @@ 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>
{ {
@@ -69,70 +67,6 @@ 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)
{ {

View File

@@ -4,24 +4,35 @@ 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 TitleEn, string Title,
string DescriptionEn, string Summary,
string TitleFr, string? Body,
string DescriptionFr); string Category,
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.TitleEn).NotEmpty().MaximumLength(160); RuleFor(x => x.Title).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000); RuleFor(x => x.Summary).NotEmpty().MaximumLength(512);
RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160); RuleFor(x => x.Body).MaximumLength(8000);
RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000); RuleFor(x => x.Category).NotEmpty().MaximumLength(32);
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);
} }
} }
@@ -52,13 +63,58 @@ internal class UpdateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
return; return;
} }
update.Title = request.TitleEn.Trim(); if (!TryParseRequest(request, out ReleaseUpdateCategory category, out ReleaseUpdateImportance importance, out ReleaseUpdateAudience audience))
update.Summary = request.DescriptionEn.Trim(); {
update.TitleFr = request.TitleFr.Trim(); await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
update.SummaryFr = request.DescriptionFr.Trim(); return;
}
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;
}
} }

View File

@@ -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<ReleaseCommitRepositoryRefreshService>(); builder.Services.AddScoped<ReleaseCommitRepositoryImportService>();
builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>(); builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>();
return builder; return builder;

View File

@@ -5,41 +5,46 @@ 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 ReleaseCommitRepositoryRefreshResult( internal sealed record ReleaseCommitRepositoryImportResult(
IReadOnlyCollection<ReleaseCommit> Commits, IReadOnlyCollection<ReleaseCommit> Commits,
string? ErrorMessage) string? ErrorMessage)
{ {
public bool IsSuccess => ErrorMessage is null; public bool IsSuccess => ErrorMessage is null;
public static ReleaseCommitRepositoryRefreshResult Success(IReadOnlyCollection<ReleaseCommit> commits) public static ReleaseCommitRepositoryImportResult Success(IReadOnlyCollection<ReleaseCommit> commits)
{ {
return new ReleaseCommitRepositoryRefreshResult(commits, null); return new ReleaseCommitRepositoryImportResult(commits, null);
} }
public static ReleaseCommitRepositoryRefreshResult Failure(string errorMessage) public static ReleaseCommitRepositoryImportResult Failure(string errorMessage)
{ {
return new ReleaseCommitRepositoryRefreshResult([], errorMessage); return new ReleaseCommitRepositoryImportResult([], errorMessage);
} }
} }
internal sealed class ReleaseCommitRepositoryRefreshService( internal sealed class ReleaseCommitRepositoryImportService(
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<ReleaseCommitRepositoryRefreshResult> FetchCommitsAsync( public async Task<ReleaseCommitRepositoryImportResult> 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 ReleaseCommitRepositoryRefreshResult.Failure(targetError ?? "Repository configuration is not valid."); return ReleaseCommitRepositoryImportResult.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))
@@ -47,11 +52,11 @@ internal sealed class ReleaseCommitRepositoryRefreshService(
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), ct); using HttpResponseMessage response = await httpClient.GetAsync(BuildRequestUri(target, request, limit), ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
return ReleaseCommitRepositoryRefreshResult.Failure( return ReleaseCommitRepositoryImportResult.Failure(
$"Repository commit refresh failed with HTTP {(int)response.StatusCode} ({response.ReasonPhrase})."); $"Repository commit import 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);
@@ -68,31 +73,56 @@ internal sealed class ReleaseCommitRepositoryRefreshService(
} }
else else
{ {
return ReleaseCommitRepositoryRefreshResult.Failure("Repository API response did not include a commit list."); return ReleaseCommitRepositoryImportResult.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, now); ReleaseCommit? commit = ToReleaseCommit(commitElement, request, now);
if (commit is not null) if (commit is not null)
{ {
commits.Add(commit); commits.Add(commit);
} }
} }
return ReleaseCommitRepositoryRefreshResult.Success(commits); return ReleaseCommitRepositoryImportResult.Success(commits);
} }
private static Uri BuildRequestUri(RepositoryApiTarget target) private static Uri BuildRequestUri(
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"] = DefaultLimit.ToString(CultureInfo.InvariantCulture), ["limit"] = limit.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)}"));
@@ -110,7 +140,7 @@ internal sealed class ReleaseCommitRepositoryRefreshService(
if (string.IsNullOrWhiteSpace(repositoryUrl)) if (string.IsNullOrWhiteSpace(repositoryUrl))
{ {
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository refresh can be used."; errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository import can be used.";
return false; return false;
} }
@@ -146,6 +176,7 @@ internal sealed class ReleaseCommitRepositoryRefreshService(
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");
@@ -180,8 +211,8 @@ internal sealed class ReleaseCommitRepositoryRefreshService(
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 = null, SourceBranch = NormalizeOptional(request.SourceBranch),
DeploymentLabel = null, DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
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,

View File

@@ -1,5 +1,4 @@
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;
@@ -7,7 +6,6 @@ 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
{ {
@@ -42,9 +40,7 @@ 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),
force: false, stoppingToken);
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);
@@ -57,7 +53,6 @@ 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

View File

@@ -1,11 +1,15 @@
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;
@@ -16,22 +20,63 @@ 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 GetReleaseNoteRecipientsAsync(ct); List<User> ownerUsers = await GetAudienceRecipientsAsync(ReleaseUpdateAudience.OrganizationOwners, 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) ||
(!force && !ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore))) !ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore))
{ {
continue; continue;
} }
@@ -41,13 +86,19 @@ 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 (!force && !ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore)) if (!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
.VisibleToUsers() .VisibleTo(audienceContext)
.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))
@@ -62,8 +113,8 @@ internal class ReleaseUpdateEmailService(
await emailSender.SendEmailAsync( await emailSender.SendEmailAsync(
user.Email, user.Email,
GetDigestSubject(user.PreferredLanguage), "What's new in Socialize",
BuildDigestEmail(unreadUpdates, user.PreferredLanguage)); BuildDigestEmail(unreadUpdates));
dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt
{ {
@@ -79,47 +130,69 @@ internal class ReleaseUpdateEmailService(
return sentCount; return sentCount;
} }
private async Task<List<User>> GetReleaseNoteRecipientsAsync(CancellationToken ct) private async Task<IReadOnlyCollection<User>> GetTestRecipientsAsync(Guid senderUserId, CancellationToken ct)
{ {
return await userManager.Users User? sender = await userManager.Users.SingleOrDefaultAsync(user => user.Id == senderUserId, ct);
.Where(user => user.EmailConfirmed && user.Email != null) return sender is null ? [] : [sender];
.OrderBy(user => user.Email)
.ToListAsync(ct);
} }
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates, string? preferredLanguage) private async Task<List<User>> GetAudienceRecipientsAsync(ReleaseUpdateAudience audience, CancellationToken ct)
{ {
bool useFrench = IsFrench(preferredLanguage); IQueryable<User> query = userManager.Users.Where(user => user.EmailConfirmed && user.Email != null);
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
string listItems = string.Join(
Environment.NewLine,
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"; if (audience == ReleaseUpdateAudience.Developers)
string linkText = useFrench ? "Ouvrir les nouveautes" : "Open What's New"; {
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 $""" return $"""
<h1>{HtmlEncode(heading)}</h1> <h1>{HtmlEncode(update.Title)}</h1>
<ul>{listItems}</ul> <p><strong>{HtmlEncode(update.Category.ToString())}</strong></p>
<p><a href="{HtmlEncode(updateUrl)}">{HtmlEncode(linkText)}</a></p> <p>{HtmlEncode(update.Summary)}</p>
{FormatBody(update.Body)}
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
"""; """;
} }
private static string GetDigestSubject(string? preferredLanguage) private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates)
{ {
return IsFrench(preferredLanguage) string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
? "Nouveautes dans Socialize" string listItems = string.Join(
: "What's new in Socialize"; Environment.NewLine,
updates.Select(update => $"<li><strong>{HtmlEncode(update.Title)}</strong><br>{HtmlEncode(update.Summary)}</li>"));
return $"""
<h1>What's new in Socialize</h1>
<ul>{listItems}</ul>
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
""";
} }
private static bool IsFrench(string? preferredLanguage) private static string FormatBody(string? body)
{ {
return string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase); return string.IsNullOrWhiteSpace(body)
? string.Empty
: $"<p>{HtmlEncode(body).Replace(Environment.NewLine, "<br>", StringComparison.Ordinal)}</p>";
} }
private static string HtmlEncode(string? value) private static string HtmlEncode(string? value)

View File

@@ -0,0 +1,28 @@
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);
}
}

View File

@@ -1,11 +1,46 @@
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 IQueryable<ReleaseUpdate> VisibleToUsers(this IQueryable<ReleaseUpdate> query) public static async Task<ReleaseUpdateAudienceContext> GetAudienceContextAsync(
AppDbContext dbContext,
ClaimsPrincipal user,
Guid userId,
CancellationToken ct)
{ {
return query.Where(update => update.Status == ReleaseUpdateStatus.Published); bool isDeveloper = user.IsInRole(KnownRoles.Developer);
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);

View File

@@ -4,6 +4,7 @@ internal class Workspace
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public required string Name { get; set; } public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; } public string? LogoUrl { get; set; }
public Guid OrganizationId { get; set; } public Guid OrganizationId { get; set; }
public Guid OwnerUserId { get; set; } public Guid OwnerUserId { get; set; }

View File

@@ -12,6 +12,7 @@ internal static class WorkspaceModelConfiguration
workspace.ToTable("Workspaces"); workspace.ToTable("Workspaces");
workspace.HasKey(x => x.Id); workspace.HasKey(x => x.Id);
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired(); workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
workspace.Property(x => x.Slug).HasMaxLength(96).IsRequired();
workspace.Property(x => x.LogoUrl).HasMaxLength(2048); workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired(); workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
workspace.Property(x => x.ApprovalMode).HasMaxLength(32).IsRequired().HasDefaultValue("Required"); workspace.Property(x => x.ApprovalMode).HasMaxLength(32).IsRequired().HasDefaultValue("Required");
@@ -22,6 +23,7 @@ internal static class WorkspaceModelConfiguration
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
workspace.HasIndex(x => x.OrganizationId); workspace.HasIndex(x => x.OrganizationId);
workspace.HasIndex(x => new { x.OrganizationId, x.Slug }).IsUnique();
workspace.HasIndex(x => x.OwnerUserId); workspace.HasIndex(x => x.OwnerUserId);
workspace.HasOne<Organization>() workspace.HasOne<Organization>()
.WithMany() .WithMany()

View File

@@ -1,15 +1,16 @@
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;
using Socialize.Api.Modules.Workspaces.Services;
namespace Socialize.Api.Modules.Workspaces.Handlers; namespace Socialize.Api.Modules.Workspaces.Handlers;
internal record CreateWorkspaceRequest( internal record CreateWorkspaceRequest(
Guid OrganizationId, Guid OrganizationId,
string Name, string Name,
string? Slug,
string TimeZone); string TimeZone);
internal class CreateWorkspaceRequestValidator internal class CreateWorkspaceRequestValidator
@@ -19,14 +20,14 @@ internal class CreateWorkspaceRequestValidator
{ {
RuleFor(x => x.OrganizationId).NotEmpty(); RuleFor(x => x.OrganizationId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256); RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug).MaximumLength(96);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128); RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
} }
} }
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()
@@ -53,6 +54,12 @@ internal class CreateWorkspaceHandler(
} }
string normalizedName = request.Name.Trim(); string normalizedName = request.Name.Trim();
string normalizedSlug = await WorkspaceSlugGenerator.CreateUniqueAsync(
dbContext,
request.OrganizationId,
string.IsNullOrWhiteSpace(request.Slug) ? normalizedName : request.Slug,
null,
ct);
string normalizedTimeZone = request.TimeZone.Trim(); string normalizedTimeZone = request.TimeZone.Trim();
Workspace workspace = new() Workspace workspace = new()
@@ -60,6 +67,7 @@ internal class CreateWorkspaceHandler(
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
OrganizationId = request.OrganizationId, OrganizationId = request.OrganizationId,
Name = normalizedName, Name = normalizedName,
Slug = normalizedSlug,
OwnerUserId = User.GetUserId(), OwnerUserId = User.GetUserId(),
TimeZone = normalizedTimeZone, TimeZone = normalizedTimeZone,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
@@ -67,7 +75,6 @@ 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, []);

View File

@@ -1,7 +1,6 @@
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;
@@ -32,8 +31,7 @@ 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()
@@ -93,7 +91,6 @@ 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(

View File

@@ -21,6 +21,7 @@ internal record WorkspaceDto(
Guid Id, Guid Id,
Guid OrganizationId, Guid OrganizationId,
string Name, string Name,
string Slug,
string? LogoUrl, string? LogoUrl,
string TimeZone, string TimeZone,
string ApprovalMode, string ApprovalMode,
@@ -38,6 +39,7 @@ internal record WorkspaceDto(
workspace.Id, workspace.Id,
workspace.OrganizationId, workspace.OrganizationId,
workspace.Name, workspace.Name,
workspace.Slug,
workspace.LogoUrl, workspace.LogoUrl,
workspace.TimeZone, workspace.TimeZone,
workspace.ApprovalMode, workspace.ApprovalMode,

View File

@@ -5,6 +5,7 @@ using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services; using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using Socialize.Api.Modules.Workspaces.Services;
namespace Socialize.Api.Modules.Workspaces.Handlers; namespace Socialize.Api.Modules.Workspaces.Handlers;
@@ -17,6 +18,7 @@ internal record UpdateApprovalStepConfigurationRequest(
internal record UpdateWorkspaceRequest( internal record UpdateWorkspaceRequest(
string Name, string Name,
string? Slug,
string TimeZone, string TimeZone,
string? ApprovalMode, string? ApprovalMode,
bool? SchedulePostsAutomaticallyOnApproval, bool? SchedulePostsAutomaticallyOnApproval,
@@ -32,6 +34,7 @@ internal class UpdateWorkspaceRequestValidator
public UpdateWorkspaceRequestValidator() public UpdateWorkspaceRequestValidator()
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(256); RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug).MaximumLength(96);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128); RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
RuleFor(x => x.ApprovalMode) RuleFor(x => x.ApprovalMode)
.Must(mode => string.IsNullOrWhiteSpace(mode) || AllowedApprovalModes.Contains(mode.Trim())) .Must(mode => string.IsNullOrWhiteSpace(mode) || AllowedApprovalModes.Contains(mode.Trim()))
@@ -106,6 +109,12 @@ internal class UpdateWorkspaceHandler(
} }
workspace.Name = request.Name.Trim(); workspace.Name = request.Name.Trim();
workspace.Slug = await WorkspaceSlugGenerator.CreateUniqueAsync(
dbContext,
workspace.OrganizationId,
string.IsNullOrWhiteSpace(request.Slug) ? workspace.Name : request.Slug,
workspace.Id,
ct);
workspace.TimeZone = request.TimeZone.Trim(); workspace.TimeZone = request.TimeZone.Trim();
workspace.ApprovalMode = nextApprovalMode; workspace.ApprovalMode = nextApprovalMode;
workspace.SchedulePostsAutomaticallyOnApproval = request.SchedulePostsAutomaticallyOnApproval ?? workspace.SchedulePostsAutomaticallyOnApproval; workspace.SchedulePostsAutomaticallyOnApproval = request.SchedulePostsAutomaticallyOnApproval ?? workspace.SchedulePostsAutomaticallyOnApproval;

View File

@@ -0,0 +1,64 @@
using System.Text;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
namespace Socialize.Api.Modules.Workspaces.Services;
internal static class WorkspaceSlugGenerator
{
public static string Normalize(string value)
{
#pragma warning disable CA1308 // Workspace slugs are intentionally lowercase external folder names.
string trimmed = value.Trim().ToLowerInvariant();
#pragma warning restore CA1308
var builder = new StringBuilder(trimmed.Length);
bool previousWasSeparator = false;
foreach (char character in trimmed)
{
if (char.IsLetterOrDigit(character))
{
builder.Append(character);
previousWasSeparator = false;
continue;
}
if (previousWasSeparator)
{
continue;
}
builder.Append('-');
previousWasSeparator = true;
}
string slug = builder.ToString().Trim('-');
return string.IsNullOrWhiteSpace(slug) ? "workspace" : slug[..Math.Min(slug.Length, 96)];
}
public static async Task<string> CreateUniqueAsync(
AppDbContext dbContext,
Guid organizationId,
string source,
Guid? excludingWorkspaceId,
CancellationToken ct)
{
string baseSlug = Normalize(source);
string candidate = baseSlug;
int suffix = 2;
while (await dbContext.Workspaces.AnyAsync(
workspace => workspace.OrganizationId == organizationId &&
workspace.Slug == candidate &&
(!excludingWorkspaceId.HasValue || workspace.Id != excludingWorkspaceId.Value),
ct))
{
string suffixText = $"-{suffix}";
int maxBaseLength = 96 - suffixText.Length;
candidate = $"{baseSlug[..Math.Min(baseSlug.Length, maxBaseLength)]}{suffixText}";
suffix++;
}
return candidate;
}
}

View File

@@ -6,7 +6,6 @@ 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;
@@ -45,8 +44,6 @@ 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);
@@ -113,7 +110,6 @@ 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();
@@ -126,7 +122,7 @@ if (!app.Environment.IsDevelopment())
app.UseHsts(); app.UseHsts();
} }
app.MapObservabilityHealthChecks(); app.UseHealthChecks("/health");
LocalBlobStorageOptions localBlobStorageOptions = app.Services LocalBlobStorageOptions localBlobStorageOptions = app.Services
.GetRequiredService<IOptions<LocalBlobStorageOptions>>() .GetRequiredService<IOptions<LocalBlobStorageOptions>>()

View File

@@ -28,13 +28,7 @@
<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>

View File

@@ -6,16 +6,64 @@ 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_maps_summary_to_description() public void ToDto_formats_breaking_change_category_for_display()
{ {
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.",
TitleFr = "Changement API", Category = ReleaseUpdateCategory.BreakingChange,
SummaryFr = "Une API du flux de travail a change.", Importance = ReleaseUpdateImportance.Important,
Audience = ReleaseUpdateAudience.Developers,
Status = ReleaseUpdateStatus.Published, Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
@@ -25,22 +73,18 @@ public class ReleaseUpdateRulesTests
ReleaseUpdateDto dto = update.ToDto(isRead: true); ReleaseUpdateDto dto = update.ToDto(isRead: true);
Assert.Equal("A workflow API changed.", dto.Description); Assert.Equal("Breaking Change", dto.Category);
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 VisibleToUsers_returns_published_updates() public void VisibleTo_returns_everyone_updates_for_any_authenticated_user()
{ {
ReleaseUpdate update = NewPublishedUpdate(); ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
List<ReleaseUpdate> visibleUpdates = new[] { update } List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable() .AsQueryable()
.VisibleToUsers() .VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: false))
.ToList(); .ToList();
Assert.Same(update, Assert.Single(visibleUpdates)); Assert.Same(update, Assert.Single(visibleUpdates));
@@ -49,17 +93,37 @@ public class ReleaseUpdateRulesTests
[Fact] [Fact]
public void VisibleTo_rejects_unpublished_updates() public void VisibleTo_rejects_unpublished_updates()
{ {
ReleaseUpdate update = NewPublishedUpdate(); ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
update.Status = ReleaseUpdateStatus.Draft; update.Status = ReleaseUpdateStatus.Draft;
List<ReleaseUpdate> visibleUpdates = new[] { update } List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable() .AsQueryable()
.VisibleToUsers() .VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: true))
.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()
{ {
@@ -108,15 +172,16 @@ public class ReleaseUpdateRulesTests
Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore)); Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore));
} }
private static ReleaseUpdate NewPublishedUpdate() private static ReleaseUpdate NewPublishedUpdate(ReleaseUpdateAudience audience)
{ {
return new ReleaseUpdate return new ReleaseUpdate
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Title = "Update", Title = "Update",
Summary = "Something changed.", Summary = "Something changed.",
TitleFr = "Mise a jour", Category = ReleaseUpdateCategory.Improvement,
SummaryFr = "Quelque chose a change.", Importance = ReleaseUpdateImportance.Normal,
Audience = audience,
Status = ReleaseUpdateStatus.Published, Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,

View File

@@ -1,49 +0,0 @@
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"));
}
}

View File

@@ -3,9 +3,8 @@ services:
image: postgres:16 image: postgres:16
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- path: /etc/socialize/socialize.env - /etc/socialize/socialize.env
- path: .deploy.env - .deploy.env
required: false
environment: environment:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
@@ -24,9 +23,8 @@ 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:
- path: /etc/socialize/socialize.env - /etc/socialize/socialize.env
- path: .deploy.env - .deploy.env
required: false
environment: environment:
ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT} ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT}
ASPNETCORE_URLS: ${ASPNETCORE_URLS} ASPNETCORE_URLS: ${ASPNETCORE_URLS}

View File

@@ -1,22 +0,0 @@
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

View File

@@ -1,95 +0,0 @@
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"
}
}

View File

@@ -1,9 +0,0 @@
modules:
http_2xx:
prober: http
timeout: 5s
http:
method: GET
preferred_ip_protocol: ip4
valid_status_codes:
- 200

View File

@@ -1,13 +0,0 @@
# 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
}

View File

@@ -1,121 +0,0 @@
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:

View File

@@ -1,485 +0,0 @@
{
"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": ""
}

View File

@@ -1,11 +0,0 @@
apiVersion: 1
providers:
- name: Socialize
orgId: 1
folder: Socialize
type: file
disableDeletion: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards

View File

@@ -1,26 +0,0 @@
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

View File

@@ -1,32 +0,0 @@
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

View File

@@ -1,42 +0,0 @@
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

View File

@@ -1,127 +0,0 @@
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.

View File

@@ -1,25 +0,0 @@
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

Some files were not shown because too many files have changed in this diff Show More