diff --git a/Dockerfile b/Dockerfile index 7efcb985..6c63ee4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,8 @@ COPY modules/Localization/src/SimpleModule.Localization.Contracts/*.csproj modul COPY modules/Localization/src/SimpleModule.Localization/*.csproj modules/Localization/src/SimpleModule.Localization/ COPY modules/Email/src/SimpleModule.Email.Contracts/*.csproj modules/Email/src/SimpleModule.Email.Contracts/ COPY modules/Email/src/SimpleModule.Email/*.csproj modules/Email/src/SimpleModule.Email/ +COPY modules/Notifications/src/SimpleModule.Notifications.Contracts/*.csproj modules/Notifications/src/SimpleModule.Notifications.Contracts/ +COPY modules/Notifications/src/SimpleModule.Notifications/*.csproj modules/Notifications/src/SimpleModule.Notifications/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting/ @@ -89,6 +91,7 @@ COPY modules/Tenants/src/SimpleModule.Tenants/package.json modules/Tenants/src/S COPY modules/Users/src/SimpleModule.Users/package.json modules/Users/src/SimpleModule.Users/ COPY modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/package.json modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/ COPY modules/Email/src/SimpleModule.Email/package.json modules/Email/src/SimpleModule.Email/ +COPY modules/Notifications/src/SimpleModule.Notifications/package.json modules/Notifications/src/SimpleModule.Notifications/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/package.json modules/RateLimiting/src/SimpleModule.RateLimiting/ COPY packages/SimpleModule.Client/package.json packages/SimpleModule.Client/ COPY packages/SimpleModule.Theme.Default/package.json packages/SimpleModule.Theme.Default/ diff --git a/Dockerfile.worker b/Dockerfile.worker index f701f58e..67c00a87 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -71,6 +71,8 @@ COPY modules/Localization/src/SimpleModule.Localization.Contracts/*.csproj modul COPY modules/Localization/src/SimpleModule.Localization/*.csproj modules/Localization/src/SimpleModule.Localization/ COPY modules/Email/src/SimpleModule.Email.Contracts/*.csproj modules/Email/src/SimpleModule.Email.Contracts/ COPY modules/Email/src/SimpleModule.Email/*.csproj modules/Email/src/SimpleModule.Email/ +COPY modules/Notifications/src/SimpleModule.Notifications.Contracts/*.csproj modules/Notifications/src/SimpleModule.Notifications.Contracts/ +COPY modules/Notifications/src/SimpleModule.Notifications/*.csproj modules/Notifications/src/SimpleModule.Notifications/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/ COPY modules/RateLimiting/src/SimpleModule.RateLimiting/*.csproj modules/RateLimiting/src/SimpleModule.RateLimiting/ diff --git a/SimpleModule.slnx b/SimpleModule.slnx index c2dba1d3..5b84407f 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -92,6 +92,11 @@ + + + + + diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/DatabaseNotificationPayload.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/DatabaseNotificationPayload.cs new file mode 100644 index 00000000..8ebb924e --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/DatabaseNotificationPayload.cs @@ -0,0 +1,20 @@ +using SimpleModule.Core; + +namespace SimpleModule.Notifications.Contracts; + +[NoDtoGeneration] +public sealed class DatabaseNotificationPayload +{ + public DatabaseNotificationPayload() { } + + public DatabaseNotificationPayload(string? title, string? body, object? data = null) + { + Title = title; + Body = body; + Data = data; + } + + public string? Title { get; set; } + public string? Body { get; set; } + public object? Data { get; set; } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationFailedEvent.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationFailedEvent.cs new file mode 100644 index 00000000..a2c3754b --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationFailedEvent.cs @@ -0,0 +1,11 @@ +using SimpleModule.Core.Events; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Contracts.Events; + +public sealed record NotificationFailedEvent( + UserId UserId, + string NotificationType, + string Channel, + string Error +) : DomainEvent; diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationSentEvent.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationSentEvent.cs new file mode 100644 index 00000000..0cb72299 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Events/NotificationSentEvent.cs @@ -0,0 +1,10 @@ +using SimpleModule.Core.Events; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Contracts.Events; + +public sealed record NotificationSentEvent( + UserId UserId, + string NotificationType, + string Channel +) : DomainEvent; diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotification.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotification.cs new file mode 100644 index 00000000..5c1a0be5 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotification.cs @@ -0,0 +1,19 @@ +namespace SimpleModule.Notifications.Contracts; + +/// +/// A notification that can be dispatched to one or more channels for a given recipient. +/// Implement to declare which channel names should attempt delivery +/// and override the per-channel To* methods to supply the channel-specific payload. +/// +public interface INotification +{ + /// Stable identifier for this notification type (e.g. "orders.shipped"). + string NotificationType { get; } + + /// Channel names to deliver this notification on (see ). + string[] Via(NotificationRecipient recipient); + + MailMessage? ToMail(NotificationRecipient recipient) => null; + DatabaseNotificationPayload? ToDatabase(NotificationRecipient recipient) => null; + SmsMessage? ToSms(NotificationRecipient recipient) => null; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotificationsContracts.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotificationsContracts.cs new file mode 100644 index 00000000..c732bd03 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotificationsContracts.cs @@ -0,0 +1,13 @@ +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Contracts; + +public interface INotificationsContracts +{ + Task> ListAsync(UserId userId, QueryNotificationsRequest request); + Task GetUnreadCountAsync(UserId userId, CancellationToken cancellationToken = default); + Task GetByIdAsync(NotificationId id, UserId userId); + Task MarkReadAsync(NotificationId id, UserId userId); + Task MarkAllReadAsync(UserId userId); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotifier.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotifier.cs new file mode 100644 index 00000000..3e0aa4ad --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/INotifier.cs @@ -0,0 +1,24 @@ +namespace SimpleModule.Notifications.Contracts; + +/// +/// High-level send API. queues delivery through the background +/// jobs system so the caller does not block on slow channels. +/// dispatches synchronously and is intended for tests and tightly-coupled flows where +/// the caller wants to surface errors immediately. +/// +public interface INotifier +{ + Task SendAsync( + NotificationRecipient recipient, + T notification, + CancellationToken cancellationToken = default + ) + where T : INotification; + + Task SendNowAsync( + NotificationRecipient recipient, + T notification, + CancellationToken cancellationToken = default + ) + where T : INotification; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/MailMessage.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/MailMessage.cs new file mode 100644 index 00000000..d833e781 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/MailMessage.cs @@ -0,0 +1,20 @@ +using SimpleModule.Core; + +namespace SimpleModule.Notifications.Contracts; + +[NoDtoGeneration] +public sealed class MailMessage +{ + public MailMessage() { } + + public MailMessage(string subject, string body, bool isHtml = false) + { + Subject = subject; + Body = body; + IsHtml = isHtml; + } + + public string Subject { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public bool IsHtml { get; set; } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/Notification.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Notification.cs new file mode 100644 index 00000000..e783d8bb --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/Notification.cs @@ -0,0 +1,21 @@ +using SimpleModule.Core; +using SimpleModule.Core.Entities; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Contracts; + +[Dto] +public class Notification : Entity +{ + public UserId UserId { get; set; } + public string Type { get; set; } = string.Empty; + public string Channel { get; set; } = string.Empty; + public string? Title { get; set; } + public string? Body { get; set; } + + // JSON payload — arbitrary, channel-specific or in-app data + public string DataJson { get; set; } = "{}"; + + public DateTimeOffset? ReadAt { get; set; } + public bool IsRead => ReadAt.HasValue; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationId.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationId.cs new file mode 100644 index 00000000..2b91e6c4 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationId.cs @@ -0,0 +1,6 @@ +using Vogen; + +namespace SimpleModule.Notifications.Contracts; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct NotificationId; diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationRecipient.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationRecipient.cs new file mode 100644 index 00000000..abd5d185 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationRecipient.cs @@ -0,0 +1,27 @@ +using SimpleModule.Core; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Contracts; + +/// +/// Identifies a recipient of a notification and supplies the per-channel addresses +/// the channel implementations need. A recipient is keyed by so +/// the database channel can persist notifications, while the optional addresses let +/// the mail/sms channels deliver out-of-band. +/// +[NoDtoGeneration] +public sealed class NotificationRecipient +{ + public NotificationRecipient() { } + + public NotificationRecipient(UserId userId, string? email = null, string? phoneNumber = null) + { + UserId = userId; + Email = email; + PhoneNumber = phoneNumber; + } + + public UserId UserId { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationsConstants.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationsConstants.cs new file mode 100644 index 00000000..9a3ba523 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/NotificationsConstants.cs @@ -0,0 +1,36 @@ +namespace SimpleModule.Notifications.Contracts; + +public static class NotificationsConstants +{ + public const string ModuleName = "Notifications"; + public const string RoutePrefix = "/api/notifications"; + public const string ViewPrefix = "/notifications"; + + public static class Channels + { + public const string Mail = "mail"; + public const string Database = "database"; + public const string Sms = "sms"; + } + + public static class Routes + { + // API endpoints + public const string List = "/"; + public const string UnreadCount = "/unread-count"; + public const string MarkRead = "/{id}/read"; + public const string MarkAllRead = "/read-all"; + public const string Send = "/send"; + + // View endpoints + public const string Inbox = "/"; + } + + public static class SettingsKeys + { + public const string MailChannelEnabled = "notifications.channel.mail.enabled"; + public const string DatabaseChannelEnabled = "notifications.channel.database.enabled"; + public const string SmsChannelEnabled = "notifications.channel.sms.enabled"; + public const string DefaultPageSize = "notifications.defaultPageSize"; + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs new file mode 100644 index 00000000..043a525c --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/QueryNotificationsRequest.cs @@ -0,0 +1,16 @@ +using SimpleModule.Core; + +namespace SimpleModule.Notifications.Contracts; + +[Dto] +public class QueryNotificationsRequest +{ + public int? Page { get; set; } + public int? PageSize { get; set; } + public bool? UnreadOnly { get; set; } + public string? Channel { get; set; } + public string? Type { get; set; } + + public int EffectivePage => Page is > 0 ? Page.Value : 1; + public int EffectivePageSize => PageSize is > 0 and <= 100 ? PageSize.Value : 20; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/SimpleModule.Notifications.Contracts.csproj b/modules/Notifications/src/SimpleModule.Notifications.Contracts/SimpleModule.Notifications.Contracts.csproj new file mode 100644 index 00000000..0843e49c --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/SimpleModule.Notifications.Contracts.csproj @@ -0,0 +1,14 @@ + + + net10.0 + Library + $(DefineConstants);VOGEN_NO_VALIDATION + Notifications module contracts for SimpleModule. Multi-channel notification dispatch (mail, database, SMS, etc.). + + + + + + + + diff --git a/modules/Notifications/src/SimpleModule.Notifications.Contracts/SmsMessage.cs b/modules/Notifications/src/SimpleModule.Notifications.Contracts/SmsMessage.cs new file mode 100644 index 00000000..e399575d --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications.Contracts/SmsMessage.cs @@ -0,0 +1,16 @@ +using SimpleModule.Core; + +namespace SimpleModule.Notifications.Contracts; + +[NoDtoGeneration] +public sealed class SmsMessage +{ + public SmsMessage() { } + + public SmsMessage(string body) + { + Body = body; + } + + public string Body { get; set; } = string.Empty; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs new file mode 100644 index 00000000..e236b145 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Channels/DatabaseChannel.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Contracts.Events; +using Wolverine.EntityFrameworkCore; + +namespace SimpleModule.Notifications.Channels; + +public partial class DatabaseChannel( + NotificationsDbContext db, + IDbContextOutbox outbox, + ILogger logger +) : INotificationChannel +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public string Name => NotificationsConstants.Channels.Database; + + public async Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ) + { + var payload = notification.ToDatabase(recipient); + if (payload is null) + { + return; + } + + var entity = new Notification + { + Id = NotificationId.From(Guid.CreateVersion7()), + UserId = recipient.UserId, + Type = notification.NotificationType, + Channel = Name, + Title = payload.Title, + Body = payload.Body, + DataJson = payload.Data is null + ? "{}" + : JsonSerializer.Serialize(payload.Data, JsonOptions), + }; + + db.Notifications.Add(entity); + + await outbox.PublishAsync( + new NotificationSentEvent(recipient.UserId, notification.NotificationType, Name) + ); + await outbox.SaveChangesAndFlushMessagesAsync(cancellationToken); + + LogPersisted(logger, entity.Id, recipient.UserId.Value, notification.NotificationType); + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Persisted notification {NotificationId} for user {UserId} type {Type}" + )] + private static partial void LogPersisted( + ILogger logger, + NotificationId notificationId, + string userId, + string type + ); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs new file mode 100644 index 00000000..ec96f708 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannel.cs @@ -0,0 +1,27 @@ +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.Channels; + +/// +/// A delivery channel — mail, database, sms, slack, push, etc. Each channel +/// implementation is registered in DI by name and the notifier dispatches to it +/// based on what returns. +/// +/// +/// Lives in the implementation assembly (not SimpleModule.Notifications.Contracts) +/// because each channel is a per-module-internal extension point with many implementations, +/// which the source generator's contract rules forbid in a Contracts assembly. Optional +/// out-of-tree channels (Slack, push, custom SMS providers) reference this assembly +/// directly the same way Email providers reference SimpleModule.Email. +/// +public interface INotificationChannel +{ + /// Channel identifier matching the names returned from . + string Name { get; } + + Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs b/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs new file mode 100644 index 00000000..8a1dd495 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Channels/INotificationChannelRegistry.cs @@ -0,0 +1,24 @@ +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.Channels; + +public interface INotificationChannelRegistry +{ + INotificationChannel? Find(string name); + IReadOnlyCollection RegisteredNames { get; } +} + +public sealed class NotificationChannelRegistry : INotificationChannelRegistry +{ + private readonly Dictionary _channels; + + public NotificationChannelRegistry(IEnumerable channels) + { + _channels = channels.ToDictionary(c => c.Name, StringComparer.OrdinalIgnoreCase); + } + + public INotificationChannel? Find(string name) => + _channels.TryGetValue(name, out var channel) ? channel : null; + + public IReadOnlyCollection RegisteredNames => _channels.Keys; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs new file mode 100644 index 00000000..28eadab3 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Channels/LogSmsChannel.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.Channels; + +/// +/// Default SMS channel: writes the message to the log. A real provider (Twilio, etc.) +/// can be plugged in by replacing this registration with another +/// implementation whose returns "sms". +/// +public partial class LogSmsChannel(ILogger logger) : INotificationChannel +{ + public string Name => NotificationsConstants.Channels.Sms; + + public Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ) + { + var sms = notification.ToSms(recipient); + if (sms is null) + { + return Task.CompletedTask; + } + + if (string.IsNullOrWhiteSpace(recipient.PhoneNumber)) + { + LogMissingPhone(logger, recipient.UserId.Value, notification.NotificationType); + return Task.CompletedTask; + } + + LogSms(logger, recipient.PhoneNumber, notification.NotificationType, sms.Body); + return Task.CompletedTask; + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "[SMS] to={Phone} type={Type} body={Body}" + )] + private static partial void LogSms(ILogger logger, string phone, string type, string body); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Skipping SMS notification for user {UserId} type {Type}: recipient has no phone number" + )] + private static partial void LogMissingPhone(ILogger logger, string userId, string type); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs b/modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs new file mode 100644 index 00000000..4d94b476 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Channels/MailChannel.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging; +using SimpleModule.Email.Contracts; +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.Channels; + +/// +/// Forwards a notification's mail payload to the Email module. Skips silently when the +/// recipient has no email address — channel selection happens upstream in the notifier, +/// but we still defend here so mis-configured callers don't fault. +/// +public partial class MailChannel(IEmailContracts email, ILogger logger) + : INotificationChannel +{ + public string Name => NotificationsConstants.Channels.Mail; + + public async Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ) + { + var mail = notification.ToMail(recipient); + if (mail is null) + { + return; + } + + if (string.IsNullOrWhiteSpace(recipient.Email)) + { + LogMissingEmail(logger, recipient.UserId.Value, notification.NotificationType); + return; + } + + await email.SendEmailAsync( + new SendEmailRequest + { + To = recipient.Email, + Subject = mail.Subject, + Body = mail.Body, + IsHtml = mail.IsHtml, + } + ); + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Skipping mail notification for user {UserId} type {Type}: recipient has no email address" + )] + private static partial void LogMissingEmail(ILogger logger, string userId, string type); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs new file mode 100644 index 00000000..4a40ed93 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/ListNotificationsEndpoint.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Extensions; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Endpoints.Notifications; + +public class ListNotificationsEndpoint : IEndpoint +{ + public const string Route = NotificationsConstants.Routes.List; + + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + Route, + ( + [AsParameters] QueryNotificationsRequest request, + HttpContext context, + INotificationsContracts notifications + ) => + notifications.ListAsync(UserId.From(context.User.GetUserId()!), request) + ) + .RequirePermission(NotificationsPermissions.ViewOwn); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs new file mode 100644 index 00000000..e9ad5a37 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkAllReadEndpoint.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Extensions; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Endpoints.Notifications; + +public class MarkAllReadEndpoint : IEndpoint +{ + public const string Route = NotificationsConstants.Routes.MarkAllRead; + public const string Method = "POST"; + + public void Map(IEndpointRouteBuilder app) => + app.MapPost( + Route, + async (HttpContext context, INotificationsContracts notifications) => + { + var marked = await notifications.MarkAllReadAsync( + UserId.From(context.User.GetUserId()!) + ); + return new { marked }; + } + ) + .RequirePermission(NotificationsPermissions.ViewOwn); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs new file mode 100644 index 00000000..185dd49e --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/MarkReadEndpoint.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Extensions; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Endpoints.Notifications; + +public class MarkReadEndpoint : IEndpoint +{ + public const string Route = NotificationsConstants.Routes.MarkRead; + public const string Method = "POST"; + + public void Map(IEndpointRouteBuilder app) => + app.MapPost( + Route, + async Task ( + Guid id, + HttpContext context, + INotificationsContracts notifications + ) => + { + var ok = await notifications.MarkReadAsync( + NotificationId.From(id), + UserId.From(context.User.GetUserId()!) + ); + return ok ? TypedResults.NoContent() : TypedResults.NotFound(); + } + ) + .RequirePermission(NotificationsPermissions.ViewOwn); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs new file mode 100644 index 00000000..82097d2d --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Endpoints/Notifications/UnreadCountEndpoint.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Extensions; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Endpoints.Notifications; + +public class UnreadCountEndpoint : IEndpoint +{ + public const string Route = NotificationsConstants.Routes.UnreadCount; + + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + Route, + async ( + HttpContext context, + INotificationsContracts notifications, + CancellationToken ct + ) => + { + var count = await notifications.GetUnreadCountAsync( + UserId.From(context.User.GetUserId()!), + ct + ); + return new { count }; + } + ) + .RequirePermission(NotificationsPermissions.ViewOwn); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs b/modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs new file mode 100644 index 00000000..3b59c640 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/EntityConfigurations/NotificationConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.Notifications.Contracts; + +namespace SimpleModule.Notifications.EntityConfigurations; + +public class NotificationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(n => n.Id); + builder.Property(n => n.UserId).IsRequired().HasMaxLength(450); + builder.Property(n => n.Type).IsRequired().HasMaxLength(200); + builder.Property(n => n.Channel).IsRequired().HasMaxLength(50); + builder.Property(n => n.Title).HasMaxLength(500); + builder.Property(n => n.Body).HasMaxLength(4000); + builder.Property(n => n.DataJson).IsRequired(); + builder.Property(n => n.ConcurrencyStamp).HasMaxLength(64); + builder.Ignore(n => n.IsRead); + + // Covers the inbox list query (filter UserId + order CreatedAt DESC, Id as tiebreaker) + // and the unread-count query (predicate on UserId + ReadAt). + builder.HasIndex(n => new { n.UserId, n.CreatedAt, n.Id }); + builder.HasIndex(n => new { n.UserId, n.ReadAt }); + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs b/modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs new file mode 100644 index 00000000..ebbe7a74 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Jobs/DispatchNotificationJob.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SimpleModule.BackgroundJobs.Contracts; +using SimpleModule.Notifications.Channels; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Contracts.Events; +using SimpleModule.Notifications.Services; +using SimpleModule.Users.Contracts; +using Wolverine; + +namespace SimpleModule.Notifications.Jobs; + +public sealed record DispatchNotificationJobData( + string UserId, + string? Email, + string? PhoneNumber, + string ChannelName, + string NotificationTypeName, + string NotificationJson +); + +public class DispatchNotificationJob( + INotificationChannelRegistry channels, + IMessageBus bus, + ILogger logger +) : IModuleJob +{ + public async Task ExecuteAsync( + IJobExecutionContext context, + CancellationToken cancellationToken + ) + { + var jobData = context.GetData(); + + var channel = channels.Find(jobData.ChannelName); + if (channel is null) + { + context.Log($"Unknown channel '{jobData.ChannelName}'; dropping notification."); + return; + } + + var notificationType = Type.GetType(jobData.NotificationTypeName); + if (notificationType is null) + { + context.Log( + $"Unable to resolve notification type {jobData.NotificationTypeName}; dropping." + ); + return; + } + + if ( + JsonSerializer.Deserialize(jobData.NotificationJson, notificationType) + is not INotification notification + ) + { + context.Log($"Failed to deserialize notification of type {notificationType.Name}."); + return; + } + + var recipient = new NotificationRecipient( + UserId.From(jobData.UserId), + jobData.Email, + jobData.PhoneNumber + ); + + try + { + await channel.SendAsync(recipient, notification, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + NotificationsLog.ChannelFailure( + logger, + jobData.ChannelName, + notification.NotificationType, + jobData.UserId, + ex + ); + + await bus.PublishAsync( + new NotificationFailedEvent( + recipient.UserId, + notification.NotificationType, + jobData.ChannelName, + ex.Message + ) + ); + throw; + } + + context.ReportProgress(100); + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs b/modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs new file mode 100644 index 00000000..7288cc8f --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/NotificationsDbContext.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.EntityConfigurations; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications; + +public class NotificationsDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet Notifications => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new NotificationConfiguration()); + modelBuilder.ApplyModuleSchema("Notifications", dbOptions.Value); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder + .Properties() + .HaveConversion< + NotificationId.EfCoreValueConverter, + NotificationId.EfCoreValueComparer + >(); + configurationBuilder + .Properties() + .HaveConversion(); + + if (dbOptions.Value.DetectProvider("Notifications") == DatabaseProvider.Sqlite) + { + configurationBuilder + .Properties() + .HaveConversion(); + configurationBuilder + .Properties() + .HaveConversion(); + } + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs b/modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs new file mode 100644 index 00000000..1c0ea43f --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/NotificationsModule.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.BackgroundJobs.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Settings; +using SimpleModule.Database; +using SimpleModule.Notifications.Channels; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Jobs; +using SimpleModule.Notifications.Services; + +namespace SimpleModule.Notifications; + +[Module( + NotificationsConstants.ModuleName, + RoutePrefix = NotificationsConstants.RoutePrefix, + ViewPrefix = NotificationsConstants.ViewPrefix +)] +public class NotificationsModule : IModule, IModuleServices +{ + public void ConfigureMiddleware(IApplicationBuilder app) + { + // DispatchNotificationJob and the Notifier rely on IBackgroundJobs (whose + // implementation lives in the BackgroundJobs module, not its Contracts assembly). + // Fail fast with a directive message if the implementation isn't installed. + var probe = app.ApplicationServices.GetRequiredService(); + if (!probe.IsService(typeof(IBackgroundJobs))) + { + throw new InvalidOperationException( + "SimpleModule.Notifications requires SimpleModule.BackgroundJobs to be installed. " + + "Add a reference to the SimpleModule.BackgroundJobs project so IBackgroundJobs can be resolved." + ); + } + } + + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddModuleDbContext( + configuration, + NotificationsConstants.ModuleName + ); + services.Configure(configuration.GetSection("Notifications")); + + services.AddScoped(); + services.AddScoped(); + + // Channels — registered as INotificationChannel so the registry can enumerate them. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddModuleJob(); + } + + public void ConfigureSettings(ISettingsBuilder settings) + { + settings + .Add( + new SettingDefinition + { + Key = NotificationsConstants.SettingsKeys.MailChannelEnabled, + DisplayName = "Mail notifications", + Description = "Receive notifications by email.", + Group = "Notifications", + Scope = SettingScope.User, + DefaultValue = "true", + Type = SettingType.Bool, + } + ) + .Add( + new SettingDefinition + { + Key = NotificationsConstants.SettingsKeys.DatabaseChannelEnabled, + DisplayName = "In-app notifications", + Description = "Show notifications in the in-app inbox.", + Group = "Notifications", + Scope = SettingScope.User, + DefaultValue = "true", + Type = SettingType.Bool, + } + ) + .Add( + new SettingDefinition + { + Key = NotificationsConstants.SettingsKeys.SmsChannelEnabled, + DisplayName = "SMS notifications", + Description = "Receive notifications by SMS (when a phone number is on file).", + Group = "Notifications", + Scope = SettingScope.User, + DefaultValue = "false", + Type = SettingType.Bool, + } + ) + .Add( + new SettingDefinition + { + Key = NotificationsConstants.SettingsKeys.DefaultPageSize, + DisplayName = "Inbox page size", + Description = "Notifications to load per page in the inbox.", + Group = "Notifications", + Scope = SettingScope.Application, + DefaultValue = "20", + Type = SettingType.Number, + } + ); + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/NotificationsModuleOptions.cs b/modules/Notifications/src/SimpleModule.Notifications/NotificationsModuleOptions.cs new file mode 100644 index 00000000..52107ab4 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/NotificationsModuleOptions.cs @@ -0,0 +1,11 @@ +using SimpleModule.Core; + +namespace SimpleModule.Notifications; + +public class NotificationsModuleOptions : IModuleOptions +{ + public bool MailChannelEnabled { get; set; } = true; + public bool DatabaseChannelEnabled { get; set; } = true; + public bool SmsChannelEnabled { get; set; } + public int DefaultPageSize { get; set; } = 20; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/NotificationsPermissions.cs b/modules/Notifications/src/SimpleModule.Notifications/NotificationsPermissions.cs new file mode 100644 index 00000000..27708688 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/NotificationsPermissions.cs @@ -0,0 +1,9 @@ +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Notifications; + +public sealed class NotificationsPermissions : IModulePermissions +{ + public const string ViewOwn = "Notifications.ViewOwn"; + public const string SendToOthers = "Notifications.SendToOthers"; +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Pages/Inbox.tsx b/modules/Notifications/src/SimpleModule.Notifications/Pages/Inbox.tsx new file mode 100644 index 00000000..ec05e9bd --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Pages/Inbox.tsx @@ -0,0 +1,81 @@ +import { router } from '@inertiajs/react'; +import { Badge, Button, Card, CardContent, PageShell } from '@simplemodule/ui'; +import type { Notification } from '../types'; + +interface Props { + items: Notification[]; + totalCount: number; + unreadCount: number; +} + +function formatDate(value: string): string { + try { + return new Date(value).toLocaleString(); + } catch { + return value; + } +} + +export default function Inbox({ items, totalCount, unreadCount }: Props) { + const markRead = (id: string) => { + router.post( + `/api/notifications/${id}/read`, + {}, + { preserveScroll: true, onSuccess: () => router.reload({ only: ['items', 'unreadCount'] }) }, + ); + }; + + const markAllRead = () => { + router.post( + '/api/notifications/read-all', + {}, + { preserveScroll: true, onSuccess: () => router.reload({ only: ['items', 'unreadCount'] }) }, + ); + }; + + return ( + +
+ +
+ + {items.length === 0 ? ( + + + You're all caught up. + + + ) : ( +
+ {items.map((n) => ( + + +
+
+ {!n.readAt && New} + {n.channel} + {n.type} +
+ {n.title &&

{n.title}

} + {n.body &&

{n.body}

} +

{formatDate(n.createdAt)}

+
+ {!n.readAt && ( + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs b/modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs new file mode 100644 index 00000000..e898d24a --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Pages/InboxEndpoint.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Extensions; +using SimpleModule.Core.Inertia; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Pages; + +public class InboxEndpoint : IViewEndpoint +{ + public const string Route = NotificationsConstants.Routes.Inbox; + + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + Route, + async ( + HttpContext context, + INotificationsContracts notifications, + bool? unreadOnly + ) => + { + var userId = UserId.From(context.User.GetUserId()!); + var page = await notifications.ListAsync( + userId, + new QueryNotificationsRequest { UnreadOnly = unreadOnly } + ); + var unreadCount = await notifications.GetUnreadCountAsync(userId); + + return Inertia.Render( + "Notifications/Inbox", + new + { + items = page.Items, + totalCount = page.TotalCount, + unreadCount, + } + ); + } + ) + .RequirePermission(NotificationsPermissions.ViewOwn); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Pages/index.ts b/modules/Notifications/src/SimpleModule.Notifications/Pages/index.ts new file mode 100644 index 00000000..9f535473 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Pages/index.ts @@ -0,0 +1,3 @@ +export const pages: Record = { + 'Notifications/Inbox': () => import('./Inbox'), +}; diff --git a/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs b/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs new file mode 100644 index 00000000..a035d2e4 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationService.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Services; + +public class NotificationService(NotificationsDbContext db) : INotificationsContracts +{ + public async Task> ListAsync( + UserId userId, + QueryNotificationsRequest request + ) + { + var query = db.Notifications.AsNoTracking().Where(n => n.UserId == userId); + + if (request.UnreadOnly == true) + { + query = query.Where(n => n.ReadAt == null); + } + if (!string.IsNullOrWhiteSpace(request.Channel)) + { + query = query.Where(n => n.Channel == request.Channel); + } + if (!string.IsNullOrWhiteSpace(request.Type)) + { + query = query.Where(n => n.Type == request.Type); + } + + var totalCount = await query.CountAsync(); + var page = request.EffectivePage; + var pageSize = request.EffectivePageSize; + + var items = await query + .OrderByDescending(n => n.CreatedAt) + .ThenByDescending(n => n.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + }; + } + + public Task GetUnreadCountAsync( + UserId userId, + CancellationToken cancellationToken = default + ) => + db.Notifications.CountAsync( + n => n.UserId == userId && n.ReadAt == null, + cancellationToken + ); + + public async Task GetByIdAsync(NotificationId id, UserId userId) => + await db.Notifications.AsNoTracking().FirstOrDefaultAsync(n => + n.Id == id && n.UserId == userId + ); + + public async Task MarkReadAsync(NotificationId id, UserId userId) + { + var notification = await db.Notifications.FirstOrDefaultAsync(n => + n.Id == id && n.UserId == userId + ); + if (notification is null) + { + return false; + } + + if (notification.ReadAt is not null) + { + return true; + } + + notification.ReadAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + return true; + } + + public async Task MarkAllReadAsync(UserId userId) + { + var now = DateTimeOffset.UtcNow; + return await db + .Notifications.Where(n => n.UserId == userId && n.ReadAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(n => n.ReadAt, now)); + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs b/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs new file mode 100644 index 00000000..07d35d3d --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Services/NotificationsLog.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Logging; + +namespace SimpleModule.Notifications.Services; + +internal static partial class NotificationsLog +{ + [LoggerMessage( + Level = LogLevel.Error, + Message = "Notification channel {Channel} failed for type {Type} user {UserId}" + )] + public static partial void ChannelFailure( + ILogger logger, + string channel, + string type, + string userId, + Exception exception + ); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Unknown notification channel '{Channel}' requested for type {Type}; skipping" + )] + public static partial void UnknownChannel(ILogger logger, string channel, string type); +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs b/modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs new file mode 100644 index 00000000..b7ae6201 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/Services/Notifier.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Logging; +using SimpleModule.BackgroundJobs.Contracts; +using SimpleModule.Notifications.Channels; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Contracts.Events; +using SimpleModule.Notifications.Jobs; +using Wolverine; + +namespace SimpleModule.Notifications.Services; + +public class Notifier( + INotificationChannelRegistry channels, + IBackgroundJobs backgroundJobs, + IMessageBus bus, + ILogger logger +) : INotifier +{ + public async Task SendAsync( + NotificationRecipient recipient, + T notification, + CancellationToken cancellationToken = default + ) + where T : INotification + { + // One job per channel so a slow/failing channel cannot block siblings and so retries are per-channel. + var channelNames = notification.Via(recipient); + var notificationJson = System.Text.Json.JsonSerializer.Serialize( + notification, + notification.GetType() + ); + var typeName = typeof(T).AssemblyQualifiedName!; + + foreach (var channelName in channelNames) + { + await backgroundJobs.EnqueueAsync( + new DispatchNotificationJobData( + recipient.UserId.Value, + recipient.Email, + recipient.PhoneNumber, + channelName, + typeName, + notificationJson + ), + cancellationToken + ); + } + } + + public async Task SendNowAsync( + NotificationRecipient recipient, + T notification, + CancellationToken cancellationToken = default + ) + where T : INotification + { + var channelNames = notification.Via(recipient); + + foreach (var channelName in channelNames) + { + var channel = channels.Find(channelName); + if (channel is null) + { + NotificationsLog.UnknownChannel(logger, channelName, notification.NotificationType); + continue; + } + + try + { + await channel.SendAsync(recipient, notification, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + NotificationsLog.ChannelFailure( + logger, + channelName, + notification.NotificationType, + recipient.UserId.Value, + ex + ); + await bus.PublishAsync( + new NotificationFailedEvent( + recipient.UserId, + notification.NotificationType, + channelName, + ex.Message + ) + ); + } + } + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/SimpleModule.Notifications.csproj b/modules/Notifications/src/SimpleModule.Notifications/SimpleModule.Notifications.csproj new file mode 100644 index 00000000..3f8421c7 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/SimpleModule.Notifications.csproj @@ -0,0 +1,21 @@ + + + net10.0 + Notifications module for SimpleModule. Multi-channel dispatch (mail, database, SMS) with in-app bell-icon UI. + + + + + + + + + + + + + + %(Filename)Endpoint.cs + + + diff --git a/modules/Notifications/src/SimpleModule.Notifications/package.json b/modules/Notifications/src/SimpleModule.Notifications/package.json new file mode 100644 index 00000000..2fd9dfea --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "name": "@simplemodule/notifications", + "version": "0.0.0", + "scripts": { + "build": "cross-env VITE_MODE=prod vite build --configLoader runner", + "build:dev": "cross-env VITE_MODE=dev vite build --configLoader runner", + "watch": "cross-env VITE_MODE=dev vite build --configLoader runner --watch" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/tsconfig.json b/modules/Notifications/src/SimpleModule.Notifications/tsconfig.json new file mode 100644 index 00000000..3e759d10 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@simplemodule/tsconfig/base", + "compilerOptions": { + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/modules/Notifications/src/SimpleModule.Notifications/types.ts b/modules/Notifications/src/SimpleModule.Notifications/types.ts new file mode 100644 index 00000000..2aed0700 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/types.ts @@ -0,0 +1,26 @@ +// Auto-generated from [Dto] types — do not edit +export interface Notification { + userId: string; + type: string; + channel: string; + title: string; + body: string; + dataJson: string; + readAt: string | null; + isRead: boolean; + id: string; + createdAt: string; + updatedAt: string; + concurrencyStamp: string; +} + +export interface QueryNotificationsRequest { + page: number | null; + pageSize: number | null; + unreadOnly: boolean | null; + channel: string; + type: string; + effectivePage: number; + effectivePageSize: number; +} + diff --git a/modules/Notifications/src/SimpleModule.Notifications/vite.config.ts b/modules/Notifications/src/SimpleModule.Notifications/vite.config.ts new file mode 100644 index 00000000..a247db62 --- /dev/null +++ b/modules/Notifications/src/SimpleModule.Notifications/vite.config.ts @@ -0,0 +1,3 @@ +import { defineModuleConfig } from '@simplemodule/client/module'; + +export default defineModuleConfig(import.meta.dirname); diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/GlobalUsings.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj b/modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj new file mode 100644 index 00000000..f741d5cf --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/SimpleModule.Notifications.Tests.csproj @@ -0,0 +1,26 @@ + + + net10.0 + false + Exe + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs new file mode 100644 index 00000000..bc744c8d --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotificationServiceTests.cs @@ -0,0 +1,134 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Services; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Notifications.Tests.Unit; + +public sealed class NotificationServiceTests : IDisposable +{ + private readonly NotificationsDbContext _db; + private readonly NotificationService _sut; + private readonly UserId _userId = UserId.From("user-1"); + + public NotificationServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + var dbOptions = Options.Create( + new DatabaseOptions + { + ModuleConnections = new Dictionary + { + ["Notifications"] = "Data Source=:memory:", + }, + } + ); + _db = new NotificationsDbContext(options, dbOptions); + _db.Database.OpenConnection(); + _db.Database.EnsureCreated(); + _sut = new NotificationService(_db); + } + + public void Dispose() => _db.Dispose(); + + private async Task SeedAsync(UserId? userId = null, DateTimeOffset? readAt = null) + { + var n = new Notification + { + Id = NotificationId.From(Guid.CreateVersion7()), + UserId = userId ?? _userId, + Type = "test.event", + Channel = NotificationsConstants.Channels.Database, + Title = "Title", + Body = "Body", + DataJson = "{}", + ReadAt = readAt, + }; + _db.Notifications.Add(n); + await _db.SaveChangesAsync(); + return n; + } + + [Fact] + public async Task ListAsync_ReturnsOnlyOwnNotifications() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(userId: UserId.From("other-user")); + + var result = await _sut.ListAsync(_userId, new QueryNotificationsRequest()); + + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(2); + } + + [Fact] + public async Task ListAsync_UnreadOnly_FiltersReadNotifications() + { + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + + var result = await _sut.ListAsync( + _userId, + new QueryNotificationsRequest { UnreadOnly = true } + ); + + result.TotalCount.Should().Be(1); + } + + [Fact] + public async Task GetUnreadCountAsync_ReturnsUnreadOnly() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + + var count = await _sut.GetUnreadCountAsync(_userId); + + count.Should().Be(2); + } + + [Fact] + public async Task MarkReadAsync_SetsReadAt() + { + var n = await SeedAsync(); + + var result = await _sut.MarkReadAsync(n.Id, _userId); + + result.Should().BeTrue(); + var refreshed = await _db.Notifications.AsNoTracking().FirstAsync(x => x.Id == n.Id); + refreshed.ReadAt.Should().NotBeNull(); + } + + [Fact] + public async Task MarkReadAsync_WithDifferentUser_ReturnsFalse() + { + var n = await SeedAsync(); + + var result = await _sut.MarkReadAsync(n.Id, UserId.From("not-the-owner")); + + result.Should().BeFalse(); + } + + [Fact] + public async Task MarkAllReadAsync_MarksAllUnreadForUser() + { + await SeedAsync(); + await SeedAsync(); + await SeedAsync(readAt: DateTimeOffset.UtcNow); + await SeedAsync(userId: UserId.From("other")); + + var marked = await _sut.MarkAllReadAsync(_userId); + + marked.Should().Be(2); + var remainingUnread = await _sut.GetUnreadCountAsync(_userId); + remainingUnread.Should().Be(0); + var otherUserUnread = await _sut.GetUnreadCountAsync(UserId.From("other")); + otherUserUnread.Should().Be(1); + } +} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs new file mode 100644 index 00000000..2c3f49d2 --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/NotifierTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using SimpleModule.Notifications.Channels; +using SimpleModule.Notifications.Contracts; +using SimpleModule.Notifications.Jobs; +using SimpleModule.Notifications.Services; +using SimpleModule.Users.Contracts; +using Wolverine; + +namespace SimpleModule.Notifications.Tests.Unit; + +public sealed class NotifierTests +{ + private sealed class CapturingChannel(string name) : INotificationChannel + { + public string Name { get; } = name; + public List<(NotificationRecipient Recipient, INotification Notification)> Calls { get; } = + []; + + public Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ) + { + Calls.Add((recipient, notification)); + return Task.CompletedTask; + } + } + + private sealed class FailingChannel(string name) : INotificationChannel + { + public string Name { get; } = name; + + public Task SendAsync( + NotificationRecipient recipient, + INotification notification, + CancellationToken cancellationToken = default + ) => throw new InvalidOperationException("boom"); + } + + private sealed record TestNotification(string[] Channels) : INotification + { + public string NotificationType => "test.event"; + + public string[] Via(NotificationRecipient recipient) => Channels; + + public DatabaseNotificationPayload? ToDatabase(NotificationRecipient recipient) => + new("Title", "Body"); + } + + [Fact] + public async Task SendNowAsync_DispatchesToEachChannel() + { + var db = new CapturingChannel(NotificationsConstants.Channels.Database); + var mail = new CapturingChannel(NotificationsConstants.Channels.Mail); + var registry = new NotificationChannelRegistry([db, mail]); + var sut = new Notifier( + registry, + new TestBackgroundJobs(), + Substitute.For(), + NullLogger.Instance + ); + + var recipient = new NotificationRecipient(UserId.From("u1"), "u1@test.com"); + var notification = new TestNotification( + [NotificationsConstants.Channels.Database, NotificationsConstants.Channels.Mail] + ); + + await sut.SendNowAsync(recipient, notification); + + db.Calls.Should().HaveCount(1); + mail.Calls.Should().HaveCount(1); + } + + [Fact] + public async Task SendNowAsync_UnknownChannelIsSkipped() + { + var db = new CapturingChannel(NotificationsConstants.Channels.Database); + var registry = new NotificationChannelRegistry([db]); + var sut = new Notifier( + registry, + new TestBackgroundJobs(), + Substitute.For(), + NullLogger.Instance + ); + + var recipient = new NotificationRecipient(UserId.From("u1")); + const string unregisteredChannel = "unregistered"; + var notification = new TestNotification( + [unregisteredChannel, NotificationsConstants.Channels.Database] + ); + + await sut.SendNowAsync(recipient, notification); + + db.Calls.Should().HaveCount(1); + } + + [Fact] + public async Task SendNowAsync_PublishesFailedEvent_WhenChannelThrows() + { + var failing = new FailingChannel(NotificationsConstants.Channels.Database); + var registry = new NotificationChannelRegistry([failing]); + var bus = Substitute.For(); + var sut = new Notifier( + registry, + new TestBackgroundJobs(), + bus, + NullLogger.Instance + ); + + var recipient = new NotificationRecipient(UserId.From("u1")); + var notification = new TestNotification([NotificationsConstants.Channels.Database]); + + await sut.SendNowAsync(recipient, notification); + + await bus.Received(1) + .PublishAsync( + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task SendAsync_EnqueuesOneJobPerChannel() + { + var registry = new NotificationChannelRegistry([]); + var jobs = new TestBackgroundJobs(); + var sut = new Notifier( + registry, + jobs, + Substitute.For(), + NullLogger.Instance + ); + + var recipient = new NotificationRecipient(UserId.From("u1"), "u1@test.com"); + var notification = new TestNotification( + [NotificationsConstants.Channels.Database, NotificationsConstants.Channels.Mail] + ); + + await sut.SendAsync(recipient, notification); + + jobs.EnqueuedJobs.Should().HaveCount(2); + jobs.EnqueuedJobs.Should().AllSatisfy(j => j.JobType.Should().Be()); + } +} diff --git a/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/TestBackgroundJobs.cs b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/TestBackgroundJobs.cs new file mode 100644 index 00000000..1c6961cd --- /dev/null +++ b/modules/Notifications/tests/SimpleModule.Notifications.Tests/Unit/TestBackgroundJobs.cs @@ -0,0 +1,41 @@ +using SimpleModule.BackgroundJobs.Contracts; + +namespace SimpleModule.Notifications.Tests.Unit; + +internal sealed class TestBackgroundJobs : IBackgroundJobs +{ + public List<(Type JobType, object? Data)> EnqueuedJobs { get; } = []; + + public Task EnqueueAsync(object? data = null, CancellationToken ct = default) + where TJob : IModuleJob + { + EnqueuedJobs.Add((typeof(TJob), data)); + return Task.FromResult(JobId.From(Guid.NewGuid())); + } + + public Task ScheduleAsync( + DateTimeOffset executeAt, + object? data = null, + CancellationToken ct = default + ) + where TJob : IModuleJob => Task.FromResult(JobId.From(Guid.NewGuid())); + + public Task AddRecurringAsync( + string name, + string cronExpression, + object? data = null, + CancellationToken ct = default + ) + where TJob : IModuleJob => Task.FromResult(RecurringJobId.From(Guid.NewGuid())); + + public Task RemoveRecurringAsync(RecurringJobId id, CancellationToken ct = default) => + Task.CompletedTask; + + public Task ToggleRecurringAsync(RecurringJobId id, CancellationToken ct = default) => + Task.FromResult(true); + + public Task CancelAsync(JobId jobId, CancellationToken ct = default) => Task.CompletedTask; + + public Task GetStatusAsync(JobId jobId, CancellationToken ct = default) => + Task.FromResult(null); +} diff --git a/package-lock.json b/package-lock.json index 7f88e90e..2a9920cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,6 +151,14 @@ "react-dom": "^19.0.0" } }, + "modules/Notifications/src/SimpleModule.Notifications": { + "name": "@simplemodule/notifications", + "version": "0.0.0", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/OpenIddict/src/SimpleModule.OpenIddict": { "name": "@simplemodule/openiddict", "version": "0.0.0", @@ -4024,6 +4032,10 @@ "resolved": "tests/k6", "link": true }, + "node_modules/@simplemodule/notifications": { + "resolved": "modules/Notifications/src/SimpleModule.Notifications", + "link": true + }, "node_modules/@simplemodule/openiddict": { "resolved": "modules/OpenIddict/src/SimpleModule.OpenIddict", "link": true diff --git a/packages/SimpleModule.Client/src/routes.ts b/packages/SimpleModule.Client/src/routes.ts index 767e4133..3a453388 100644 --- a/packages/SimpleModule.Client/src/routes.ts +++ b/packages/SimpleModule.Client/src/routes.ts @@ -192,6 +192,17 @@ export const routes = { templates: () => '/email/templates' as const, }, }, + notifications: { + api: { + listNotifications: () => '/api/notifications' as const, + markAllRead: () => '/api/notifications/read-all' as const, + markRead: (id: string | number) => `/api/notifications/${id}/read`, + unreadCount: () => '/api/notifications/unread-count' as const, + }, + views: { + inbox: () => '/notifications' as const, + }, + }, openIddict: { api: { authorization: () => '/connect/authorize' as const, diff --git a/template/SimpleModule.Host/Migrations/20260511185843_AddNotificationsModule.Designer.cs b/template/SimpleModule.Host/Migrations/20260511185843_AddNotificationsModule.Designer.cs new file mode 100644 index 00000000..192c7568 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260511185843_AddNotificationsModule.Designer.cs @@ -0,0 +1,1447 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SimpleModule.Host; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + [DbContext(typeof(HostDbContext))] + [Migration("20260511185843_AddNotificationsModule")] + partial class AddNotificationsModule + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("Users_AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ClientSecret") + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("JsonWebKeySet") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedirectUris") + .HasColumnType("TEXT"); + + b.Property("Requirements") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Scopes") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Descriptions") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Resources") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("AuthorizationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Payload") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedemptionDate") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SimpleModule.AuditLogs.Contracts.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Changes") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("EntityId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("HttpMethod") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("QueryString") + .HasColumnType("TEXT"); + + b.Property("RequestBody") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("StatusCode") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("Path"); + + b.HasIndex("Source"); + + b.HasIndex("StatusCode"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.HasIndex("EntityType", "EntityId"); + + b.HasIndex("Module", "Timestamp") + .IsDescending(false, true); + + b.HasIndex("UserId", "Timestamp") + .IsDescending(false, true); + + b.ToTable("AuditLogs_AuditEntries", (string)null); + }); + + modelBuilder.Entity("SimpleModule.BackgroundJobs.Contracts.JobProgress", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("JobTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Logs") + .HasColumnType("TEXT"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProgressMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ProgressPercentage") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleName"); + + b.ToTable("BackgroundJobs_JobProgress", (string)null); + }); + + modelBuilder.Entity("SimpleModule.BackgroundJobs.Contracts.JobQueueEntryEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("ClaimedAt") + .HasColumnType("TEXT"); + + b.Property("ClaimedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CronExpression") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("JobTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RecurringName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("SerializedData") + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecurringName") + .HasDatabaseName("IX_JobQueueEntries_RecurringName"); + + b.HasIndex("State", "ScheduledAt") + .HasDatabaseName("IX_JobQueueEntries_State_ScheduledAt"); + + b.ToTable("BackgroundJobs_JobQueueEntries", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Email.Contracts.EmailMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bcc") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Cc") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsHtml") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ReplyTo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TemplateSlug") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("Email_EmailMessages", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Email.Contracts.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultReplyTo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsHtml") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Email_EmailTemplates", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Contracts.FeatureFlagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeprecated") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlags", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Contracts.FeatureFlagOverrideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FlagName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("OverrideType") + .HasColumnType("INTEGER"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FlagName", "OverrideType", "OverrideValue") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlagOverrides", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FileStorage.Contracts.StoredFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Folder") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("Folder"); + + b.HasIndex("Folder", "FileName") + .IsUnique(); + + b.ToTable("FileStorage_StoredFiles", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Notifications.Contracts.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ReadAt"); + + b.HasIndex("UserId", "CreatedAt", "Id"); + + b.ToTable("Notifications_Notifications", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Contracts.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("RoleId", "Permission"); + + b.ToTable("Permissions_RolePermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Contracts.UserPermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Permission"); + + b.ToTable("Permissions_UserPermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.RateLimiting.Contracts.RateLimitRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EndpointPattern") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PermitLimit") + .HasColumnType("INTEGER"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PolicyType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QueueLimit") + .HasColumnType("INTEGER"); + + b.Property("ReplenishmentPeriodSeconds") + .HasColumnType("INTEGER"); + + b.Property("SegmentsPerWindow") + .HasColumnType("INTEGER"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TokenLimit") + .HasColumnType("INTEGER"); + + b.Property("TokensPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WindowSeconds") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName") + .IsUnique(); + + b.ToTable("RateLimiting_Rules", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Settings.Contracts.PublicMenuItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CssClass") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsHomePage") + .HasColumnType("INTEGER"); + + b.Property("IsVisible") + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("OpenInNewTab") + .HasColumnType("INTEGER"); + + b.Property("PageRoute") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentId", "SortOrder"); + + b.ToTable("Settings_PublicMenuItems", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Settings.Contracts.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Scope", "UserId") + .IsUnique(); + + b.ToTable("Settings_Settings", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Tenants.Contracts.TenantEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdminEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConnectionString") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("TEXT"); + + b.Property("EditionName") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedBy") + .HasColumnType("TEXT"); + + b.Property("ValidUpTo") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants_Tenants", (string)null); + + b.HasData( + new + { + Id = 1, + AdminEmail = "admin@acme.com", + ConcurrencyStamp = "seed-acme", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + EditionName = "Enterprise", + Name = "Acme Corporation", + Slug = "acme", + Status = 0, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }, + new + { + Id = 2, + AdminEmail = "admin@contoso.com", + ConcurrencyStamp = "seed-contoso", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + EditionName = "Standard", + Name = "Contoso Ltd", + Slug = "contoso", + Status = 0, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }, + new + { + Id = 3, + AdminEmail = "admin@suspended.com", + ConcurrencyStamp = "seed-suspended", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Name = "Suspended Corp", + Slug = "suspended-corp", + Status = 1, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }); + }); + + modelBuilder.Entity("SimpleModule.Tenants.Contracts.TenantHostEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HostName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("TenantId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HostName") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Tenants_TenantHosts", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "seed-host-1", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HostName = "acme.localhost", + IsActive = true, + TenantId = 1, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }, + new + { + Id = 2, + ConcurrencyStamp = "seed-host-2", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HostName = "acme.local", + IsActive = true, + TenantId = 1, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }, + new + { + Id = 3, + ConcurrencyStamp = "seed-host-3", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HostName = "contoso.localhost", + IsActive = true, + TenantId = 2, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }); + }); + + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Users_AspNetRoles", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Users.Contracts.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeactivatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users_AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SimpleModule.Users.Contracts.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SimpleModule.Settings.Contracts.PublicMenuItemEntity", b => + { + b.HasOne("SimpleModule.Settings.Contracts.PublicMenuItemEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("SimpleModule.Tenants.Contracts.TenantHostEntity", b => + { + b.HasOne("SimpleModule.Tenants.Contracts.TenantEntity", "Tenant") + .WithMany("Hosts") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("SimpleModule.Settings.Contracts.PublicMenuItemEntity", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("SimpleModule.Tenants.Contracts.TenantEntity", b => + { + b.Navigation("Hosts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/template/SimpleModule.Host/Migrations/20260511185843_AddNotificationsModule.cs b/template/SimpleModule.Host/Migrations/20260511185843_AddNotificationsModule.cs new file mode 100644 index 00000000..43d6f3b6 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260511185843_AddNotificationsModule.cs @@ -0,0 +1,1425 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace SimpleModule.Host.Migrations +{ + /// + public partial class AddNotificationsModule : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "Agents_Messages"); + + migrationBuilder.DropTable(name: "Agents_Sessions"); + + migrationBuilder.DropTable(name: "Chat_ChatMessages"); + + migrationBuilder.DropTable(name: "Datasets_Datasets"); + + migrationBuilder.DropTable(name: "Map_Basemaps"); + + migrationBuilder.DropTable(name: "Map_LayerSources"); + + migrationBuilder.DropTable(name: "Map_MapBasemap"); + + migrationBuilder.DropTable(name: "Map_MapLayer"); + + migrationBuilder.DropTable(name: "Orders_OrderItems"); + + migrationBuilder.DropTable(name: "PageBuilder_Tags"); + + migrationBuilder.DropTable(name: "PageBuilder_Templates"); + + migrationBuilder.DropTable(name: "Products_Products"); + + migrationBuilder.DropTable(name: "Rag_CachedStructuredKnowledge"); + + migrationBuilder.DropTable(name: "Chat_Conversations"); + + migrationBuilder.DropTable(name: "Map_SavedMaps"); + + migrationBuilder.DropTable(name: "Orders_Orders"); + + migrationBuilder.DropTable(name: "PageBuilder_Pages"); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Tenants_Tenants", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Tenants_TenantHosts", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Settings_Settings", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Settings_PublicMenuItems", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "RateLimiting_Rules", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "FileStorage_StoredFiles", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "FeatureFlags_FeatureFlags", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "FeatureFlags_FeatureFlagOverrides", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Email_EmailTemplates", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Email_EmailMessages", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "AuditLogs_AuditEntries", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .OldAnnotation("Sqlite:Autoincrement", true); + + migrationBuilder.CreateTable( + name: "Notifications_Notifications", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", maxLength: 450, nullable: false), + Type = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Channel = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Title = table.Column(type: "TEXT", maxLength: 500, nullable: true), + Body = table.Column(type: "TEXT", maxLength: 4000, nullable: true), + DataJson = table.Column(type: "TEXT", nullable: false), + ReadAt = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + ConcurrencyStamp = table.Column( + type: "TEXT", + maxLength: 64, + nullable: false + ), + }, + constraints: table => + { + table.PrimaryKey("PK_Notifications_Notifications", x => x.Id); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_Notifications_UserId_CreatedAt_Id", + table: "Notifications_Notifications", + columns: new[] { "UserId", "CreatedAt", "Id" } + ); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_Notifications_UserId_ReadAt", + table: "Notifications_Notifications", + columns: new[] { "UserId", "ReadAt" } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "Notifications_Notifications"); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Tenants_Tenants", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Tenants_TenantHosts", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Settings_Settings", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Settings_PublicMenuItems", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "RateLimiting_Rules", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "FileStorage_StoredFiles", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "FeatureFlags_FeatureFlags", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "FeatureFlags_FeatureFlagOverrides", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Email_EmailTemplates", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "Email_EmailMessages", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder + .AlterColumn( + name: "Id", + table: "AuditLogs_AuditEntries", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER" + ) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder.CreateTable( + name: "Agents_Messages", + columns: table => new + { + Id = table.Column(type: "TEXT", maxLength: 36, nullable: false), + Content = table.Column(type: "TEXT", nullable: false), + Role = table.Column(type: "TEXT", maxLength: 50, nullable: false), + SessionId = table.Column(type: "TEXT", maxLength: 36, nullable: false), + Timestamp = table.Column(type: "TEXT", nullable: false), + TokenCount = table.Column(type: "INTEGER", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_Agents_Messages", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Agents_Sessions", + columns: table => new + { + Id = table.Column(type: "TEXT", maxLength: 36, nullable: false), + AgentName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + ConcurrencyStamp = table.Column( + type: "TEXT", + maxLength: 64, + nullable: false + ), + CreatedAt = table.Column(type: "TEXT", nullable: false), + LastMessageAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", maxLength: 256, nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_Agents_Sessions", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Chat_Conversations", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AgentName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + ConcurrencyStamp = table.Column( + type: "TEXT", + maxLength: 64, + nullable: false + ), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + Pinned = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 512, nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false), + UserId = table.Column(type: "TEXT", maxLength: 256, nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Chat_Conversations", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Datasets_Datasets", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + BboxMaxX = table.Column(type: "REAL", nullable: true), + BboxMaxY = table.Column(type: "REAL", nullable: true), + BboxMinX = table.Column(type: "REAL", nullable: true), + BboxMinY = table.Column(type: "REAL", nullable: true), + ConcurrencyStamp = table.Column( + type: "TEXT", + maxLength: 64, + nullable: false + ), + ContentHash = table.Column( + type: "TEXT", + maxLength: 128, + nullable: true + ), + CreatedAt = table.Column(type: "TEXT", nullable: false), + CreatedBy = table.Column(type: "TEXT", nullable: true), + DeletedAt = table.Column(type: "TEXT", nullable: true), + DeletedBy = table.Column(type: "TEXT", nullable: true), + ErrorMessage = table.Column( + type: "TEXT", + maxLength: 4096, + nullable: true + ), + FeatureCount = table.Column(type: "INTEGER", nullable: true), + Format = table.Column(type: "INTEGER", nullable: false), + IsDeleted = table.Column(type: "INTEGER", nullable: false), + MetadataJson = table.Column(type: "TEXT", nullable: true), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: false), + NormalizedPath = table.Column( + type: "TEXT", + maxLength: 1024, + nullable: true + ), + OriginalFileName = table.Column( + type: "TEXT", + maxLength: 512, + nullable: false + ), + ProcessedAt = table.Column(type: "TEXT", nullable: true), + SizeBytes = table.Column(type: "INTEGER", nullable: false), + SourceSrid = table.Column(type: "INTEGER", nullable: true), + Srid = table.Column(type: "INTEGER", nullable: true), + Status = table.Column(type: "INTEGER", nullable: false), + StoragePath = table.Column( + type: "TEXT", + maxLength: 1024, + nullable: false + ), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedBy = table.Column(type: "TEXT", nullable: true), + Version = table.Column(type: "INTEGER", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Datasets_Datasets", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Map_Basemaps", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Attribution = table.Column( + type: "TEXT", + maxLength: 500, + nullable: true + ), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + CreatedBy = table.Column(type: "TEXT", nullable: true), + Description = table.Column( + type: "TEXT", + maxLength: 2000, + nullable: true + ), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + StyleUrl = table.Column(type: "TEXT", maxLength: 2048, nullable: false), + ThumbnailUrl = table.Column( + type: "TEXT", + maxLength: 2048, + nullable: true + ), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedBy = table.Column(type: "TEXT", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_Map_Basemaps", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Map_LayerSources", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Attribution = table.Column( + type: "TEXT", + maxLength: 500, + nullable: true + ), + Bounds = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + CreatedBy = table.Column(type: "TEXT", nullable: true), + Description = table.Column( + type: "TEXT", + maxLength: 2000, + nullable: true + ), + MaxZoom = table.Column(type: "INTEGER", nullable: true), + Metadata = table.Column(type: "TEXT", nullable: false), + MinZoom = table.Column(type: "INTEGER", nullable: true), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedBy = table.Column(type: "TEXT", nullable: true), + Url = table.Column(type: "TEXT", maxLength: 2048, nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Map_LayerSources", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Map_SavedMaps", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + BaseStyleUrl = table.Column( + type: "TEXT", + maxLength: 2048, + nullable: false + ), + Bearing = table.Column(type: "REAL", nullable: false), + CenterLat = table.Column(type: "REAL", nullable: false), + CenterLng = table.Column(type: "REAL", nullable: false), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + CreatedBy = table.Column(type: "TEXT", nullable: true), + Description = table.Column( + type: "TEXT", + maxLength: 2000, + nullable: true + ), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Pitch = table.Column(type: "REAL", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedBy = table.Column(type: "TEXT", nullable: true), + Zoom = table.Column(type: "REAL", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Map_SavedMaps", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Orders_Orders", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ConcurrencyStamp = table.Column( + type: "TEXT", + maxLength: 64, + nullable: false + ), + CreatedAt = table.Column(type: "TEXT", nullable: false), + CreatedBy = table.Column(type: "TEXT", nullable: true), + Total = table.Column(type: "decimal(18,2)", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedBy = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Orders_Orders", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "PageBuilder_Pages", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ConcurrencyStamp = table.Column( + type: "TEXT", + maxLength: 64, + nullable: false + ), + Content = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + CreatedBy = table.Column(type: "TEXT", nullable: true), + DeletedAt = table.Column(type: "TEXT", nullable: true), + DeletedBy = table.Column(type: "TEXT", nullable: true), + DraftContent = table.Column(type: "TEXT", nullable: true), + IsDeleted = table.Column(type: "INTEGER", nullable: false), + IsPublished = table.Column( + type: "INTEGER", + nullable: false, + defaultValue: false + ), + MetaDescription = table.Column( + type: "TEXT", + maxLength: 300, + nullable: true + ), + MetaKeywords = table.Column( + type: "TEXT", + maxLength: 500, + nullable: true + ), + OgImage = table.Column(type: "TEXT", maxLength: 500, nullable: true), + Order = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + Slug = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedBy = table.Column(type: "TEXT", nullable: true), + Version = table.Column(type: "INTEGER", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_PageBuilder_Pages", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "PageBuilder_Templates", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ConcurrencyStamp = table.Column( + type: "TEXT", + maxLength: 64, + nullable: false + ), + Content = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_PageBuilder_Templates", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Products_Products", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ConcurrencyStamp = table.Column( + type: "TEXT", + maxLength: 64, + nullable: false + ), + CreatedAt = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Price = table.Column(type: "decimal(18,2)", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Products_Products", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Rag_CachedStructuredKnowledge", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CollectionName = table.Column( + type: "TEXT", + maxLength: 256, + nullable: false + ), + CreatedAt = table.Column(type: "TEXT", nullable: false), + DocumentHash = table.Column( + type: "TEXT", + maxLength: 64, + nullable: false + ), + ExpiresAt = table.Column(type: "TEXT", nullable: true), + HitCount = table.Column(type: "INTEGER", nullable: false), + SourceTitle = table.Column( + type: "TEXT", + maxLength: 512, + nullable: false + ), + StructureType = table.Column(type: "INTEGER", nullable: false), + StructuredContent = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Rag_CachedStructuredKnowledge", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "Chat_ChatMessages", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ConcurrencyStamp = table.Column( + type: "TEXT", + maxLength: 64, + nullable: false + ), + Content = table.Column(type: "TEXT", nullable: false), + ConversationId = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + Role = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Chat_ChatMessages", x => x.Id); + table.ForeignKey( + name: "FK_Chat_ChatMessages_Chat_Conversations_ConversationId", + column: x => x.ConversationId, + principalTable: "Chat_Conversations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "Map_MapBasemap", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + BasemapId = table.Column(type: "TEXT", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false), + SavedMapId = table.Column(type: "TEXT", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Map_MapBasemap", x => x.Id); + table.ForeignKey( + name: "FK_Map_MapBasemap_Map_SavedMaps_SavedMapId", + column: x => x.SavedMapId, + principalTable: "Map_SavedMaps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "Map_MapLayer", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + LayerSourceId = table.Column(type: "TEXT", nullable: false), + Opacity = table.Column(type: "REAL", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false), + SavedMapId = table.Column(type: "TEXT", nullable: false), + StyleOverrides = table.Column(type: "TEXT", nullable: false), + Visible = table.Column(type: "INTEGER", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Map_MapLayer", x => x.Id); + table.ForeignKey( + name: "FK_Map_MapLayer_Map_SavedMaps_SavedMapId", + column: x => x.SavedMapId, + principalTable: "Map_SavedMaps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "Orders_OrderItems", + columns: table => new + { + OrderId = table.Column(type: "INTEGER", nullable: false), + ProductId = table.Column(type: "INTEGER", nullable: false), + Quantity = table.Column(type: "INTEGER", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_Orders_OrderItems", x => new { x.OrderId, x.ProductId }); + table.ForeignKey( + name: "FK_Orders_OrderItems_Orders_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders_Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "PageBuilder_Tags", + columns: table => new + { + Id = table + .Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + PageId = table.Column(type: "INTEGER", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_PageBuilder_Tags", x => x.Id); + table.ForeignKey( + name: "FK_PageBuilder_Tags_PageBuilder_Pages_PageId", + column: x => x.PageId, + principalTable: "PageBuilder_Pages", + principalColumn: "Id" + ); + } + ); + + migrationBuilder.InsertData( + table: "Map_Basemaps", + columns: new[] + { + "Id", + "Attribution", + "ConcurrencyStamp", + "CreatedAt", + "CreatedBy", + "Description", + "Name", + "StyleUrl", + "ThumbnailUrl", + "UpdatedAt", + "UpdatedBy", + }, + values: new object[,] + { + { + new Guid("22222222-2222-2222-2222-000000000001"), + "MapLibre", + "seed-basemap-demotiles", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "Official MapLibre demo vector style. Free for development.", + "MapLibre Demotiles", + "https://demotiles.maplibre.org/style.json", + null, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + }, + { + new Guid("22222222-2222-2222-2222-000000000002"), + "© OpenStreetMap contributors, OpenFreeMap", + "seed-basemap-openfreemap-liberty", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "OpenFreeMap free vector basemap, Liberty style.", + "OpenFreeMap Liberty", + "https://tiles.openfreemap.org/styles/liberty", + null, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + }, + { + new Guid("22222222-2222-2222-2222-000000000003"), + "© OpenStreetMap contributors, OpenFreeMap", + "seed-basemap-openfreemap-positron", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "OpenFreeMap free vector basemap, light Positron style.", + "OpenFreeMap Positron", + "https://tiles.openfreemap.org/styles/positron", + null, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + }, + { + new Guid("22222222-2222-2222-2222-000000000004"), + "© OpenStreetMap contributors, OpenFreeMap", + "seed-basemap-openfreemap-bright", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "OpenFreeMap free vector basemap, Bright style.", + "OpenFreeMap Bright", + "https://tiles.openfreemap.org/styles/bright", + null, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + }, + { + new Guid("22222222-2222-2222-2222-000000000005"), + "© OpenStreetMap contributors, VersaTiles", + "seed-basemap-versatiles-colorful", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "VersaTiles free OSM-based vector basemap, Colorful style.", + "Versatiles Colorful", + "https://tiles.versatiles.org/assets/styles/colorful/style.json", + null, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + }, + } + ); + + migrationBuilder.InsertData( + table: "Map_LayerSources", + columns: new[] + { + "Id", + "Attribution", + "Bounds", + "ConcurrencyStamp", + "CreatedAt", + "CreatedBy", + "Description", + "MaxZoom", + "Metadata", + "MinZoom", + "Name", + "Type", + "UpdatedAt", + "UpdatedBy", + "Url", + }, + values: new object[,] + { + { + new Guid("11111111-1111-1111-1111-000000000001"), + "© OpenStreetMap contributors", + "[-180,-85,180,85]", + "seed-osm-xyz", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "Standard OSM raster tiles. Free for low-volume use; respect the OSMF tile usage policy.", + 19, + "{}", + 0, + "OpenStreetMap (raster tiles)", + 3, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + }, + { + new Guid("11111111-1111-1111-1111-000000000002"), + "© OpenStreetMap contributors, terrestris", + "[-180,-85,180,85]", + "seed-terrestris-wms", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "Public WMS by terrestris. Used in the official MapLibre 'Add a WMS source' example.", + null, + "{\"layers\":\"OSM-WMS\",\"format\":\"image/png\",\"version\":\"1.1.1\",\"crs\":\"EPSG:3857\",\"transparent\":\"true\"}", + null, + "terrestris OSM-WMS", + 0, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "https://ows.terrestris.de/osm/service", + }, + { + new Guid("11111111-1111-1111-1111-000000000003"), + "© OpenStreetMap contributors, terrestris", + "[-180,-85,180,85]", + "seed-terrestris-topo", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "terrestris topographic WMS overlay layer (transparent).", + null, + "{\"layers\":\"TOPO-WMS,OSM-Overlay-WMS\",\"format\":\"image/png\",\"version\":\"1.1.1\",\"crs\":\"EPSG:3857\",\"transparent\":\"true\"}", + null, + "terrestris TOPO-WMS", + 0, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "https://ows.terrestris.de/osm/service", + }, + { + new Guid("11111111-1111-1111-1111-000000000004"), + "MapLibre", + "[-180,-85,180,85]", + "seed-maplibre-demotiles", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "Official MapLibre demo MVT vector tileset. Free for development.", + 14, + "{\"sourceLayer\":\"countries\"}", + 0, + "MapLibre demotiles (vector)", + 4, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.pbf", + }, + { + new Guid("11111111-1111-1111-1111-000000000005"), + "© OpenStreetMap contributors, Protomaps", + "[11.154,43.727,11.328,43.823]", + "seed-protomaps-firenze", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "Public PMTiles vector archive of Florence (ODbL). Used in the MapLibre PMTiles example.", + null, + "{\"tileType\":\"vector\",\"sourceLayer\":\"landuse\"}", + null, + "Protomaps Firenze (PMTiles)", + 5, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "https://pmtiles.io/protomaps(vector)ODbL_firenze.pmtiles", + }, + { + new Guid("11111111-1111-1111-1111-000000000006"), + "Geomatico", + "[-180,-85,180,85]", + "seed-geomatico-cog", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "Public Cloud-Optimized GeoTIFF demo from the maplibre-cog-protocol sample viewer.", + null, + "{}", + null, + "Geomatico kriging COG (demo)", + 6, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "https://labs.geomatico.es/maplibre-cog-protocol/data/kriging.tif", + }, + { + new Guid("11111111-1111-1111-1111-000000000007"), + "USGS / MapLibre demo", + "[-180,-85,180,85]", + "seed-maplibre-earthquakes", + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "Small public GeoJSON FeatureCollection from the MapLibre demo assets.", + null, + "{\"color\":\"#ef4444\"}", + null, + "MapLibre demotiles point sample (GeoJSON)", + 7, + new DateTimeOffset( + new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + null, + "https://maplibre.org/maplibre-gl-js/docs/assets/significant-earthquakes-2015.geojson", + }, + } + ); + + migrationBuilder.InsertData( + table: "Products_Products", + columns: new[] + { + "Id", + "ConcurrencyStamp", + "CreatedAt", + "Name", + "Price", + "UpdatedAt", + }, + values: new object[,] + { + { + 1, + "", + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Fantastic Rubber Shoes", + 991.68m, + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 2, + "", + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Fantastic Rubber Bacon", + 446.22m, + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 3, + "", + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Fantastic Concrete Bike", + 660.12m, + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 4, + "", + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Handcrafted Concrete Keyboard", + 633.67m, + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 5, + "", + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Intelligent Frozen Mouse", + 674.30m, + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 6, + "", + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Sleek Soft Hat", + 851.63m, + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 7, + "", + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Practical Fresh Bike", + 417.48m, + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 8, + "", + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Handmade Steel Ball", + 975.56m, + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 9, + "", + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Ergonomic Fresh Pants", + 928.09m, + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + { + 10, + "", + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + "Licensed Steel Sausages", + 592.60m, + new DateTimeOffset( + new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + new TimeSpan(0, 0, 0, 0, 0) + ), + }, + } + ); + + migrationBuilder.CreateIndex( + name: "IX_Agents_Messages_SessionId", + table: "Agents_Messages", + column: "SessionId" + ); + + migrationBuilder.CreateIndex( + name: "IX_Agents_Messages_SessionId_Timestamp", + table: "Agents_Messages", + columns: new[] { "SessionId", "Timestamp" } + ); + + migrationBuilder.CreateIndex( + name: "IX_Agents_Sessions_AgentName", + table: "Agents_Sessions", + column: "AgentName" + ); + + migrationBuilder.CreateIndex( + name: "IX_Agents_Sessions_UserId", + table: "Agents_Sessions", + column: "UserId" + ); + + migrationBuilder.CreateIndex( + name: "IX_Chat_ChatMessages_ConversationId_CreatedAt", + table: "Chat_ChatMessages", + columns: new[] { "ConversationId", "CreatedAt" } + ); + + migrationBuilder.CreateIndex( + name: "IX_Chat_Conversations_UserId_UpdatedAt", + table: "Chat_Conversations", + columns: new[] { "UserId", "UpdatedAt" } + ); + + migrationBuilder.CreateIndex( + name: "IX_Datasets_Datasets_BboxMinX_BboxMaxX_BboxMinY_BboxMaxY", + table: "Datasets_Datasets", + columns: new[] { "BboxMinX", "BboxMaxX", "BboxMinY", "BboxMaxY" } + ); + + migrationBuilder.CreateIndex( + name: "IX_Datasets_Datasets_ContentHash", + table: "Datasets_Datasets", + column: "ContentHash" + ); + + migrationBuilder.CreateIndex( + name: "IX_Datasets_Datasets_Format", + table: "Datasets_Datasets", + column: "Format" + ); + + migrationBuilder.CreateIndex( + name: "IX_Datasets_Datasets_IsDeleted_CreatedAt", + table: "Datasets_Datasets", + columns: new[] { "IsDeleted", "CreatedAt" } + ); + + migrationBuilder.CreateIndex( + name: "IX_Datasets_Datasets_Status", + table: "Datasets_Datasets", + column: "Status" + ); + + migrationBuilder.CreateIndex( + name: "IX_Map_MapBasemap_SavedMapId", + table: "Map_MapBasemap", + column: "SavedMapId" + ); + + migrationBuilder.CreateIndex( + name: "IX_Map_MapLayer_SavedMapId", + table: "Map_MapLayer", + column: "SavedMapId" + ); + + migrationBuilder.CreateIndex( + name: "IX_PageBuilder_Pages_IsDeleted_DeletedAt", + table: "PageBuilder_Pages", + columns: new[] { "IsDeleted", "DeletedAt" } + ); + + migrationBuilder.CreateIndex( + name: "IX_PageBuilder_Pages_IsPublished", + table: "PageBuilder_Pages", + column: "IsPublished" + ); + + migrationBuilder.CreateIndex( + name: "IX_PageBuilder_Pages_Slug", + table: "PageBuilder_Pages", + column: "Slug", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_PageBuilder_Tags_Name", + table: "PageBuilder_Tags", + column: "Name", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_PageBuilder_Tags_PageId", + table: "PageBuilder_Tags", + column: "PageId" + ); + + migrationBuilder.CreateIndex( + name: "IX_PageBuilder_Templates_Name", + table: "PageBuilder_Templates", + column: "Name", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_Rag_CachedStructuredKnowledge_CollectionName_DocumentHash_StructureType", + table: "Rag_CachedStructuredKnowledge", + columns: new[] { "CollectionName", "DocumentHash", "StructureType" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_Rag_CachedStructuredKnowledge_ExpiresAt", + table: "Rag_CachedStructuredKnowledge", + column: "ExpiresAt" + ); + } + } +} diff --git a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs index 30678355..96aafd95 100644 --- a/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs +++ b/template/SimpleModule.Host/Migrations/HostDbContextModelSnapshot.cs @@ -327,79 +327,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("OpenIddict_OpenIddictTokens", (string)null); }); - modelBuilder.Entity("SimpleModule.Agents.Module.AgentMessage", b => - { - b.Property("Id") - .HasMaxLength(36) - .HasColumnType("TEXT"); - - b.Property("Content") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Role") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("TEXT"); - - b.Property("SessionId") - .IsRequired() - .HasMaxLength(36) - .HasColumnType("TEXT"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.Property("TokenCount") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("SessionId"); - - b.HasIndex("SessionId", "Timestamp"); - - b.ToTable("Agents_Messages", (string)null); - }); - - modelBuilder.Entity("SimpleModule.Agents.Module.AgentSession", b => - { - b.Property("Id") - .HasMaxLength(36) - .HasColumnType("TEXT"); - - b.Property("AgentName") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("LastMessageAt") - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AgentName"); - - b.HasIndex("UserId"); - - b.ToTable("Agents_Sessions", (string)null); - }); - modelBuilder.Entity("SimpleModule.AuditLogs.Contracts.AuditEntry", b => { b.Property("Id") @@ -497,7 +424,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AuditLogs_AuditEntries", (string)null); }); - modelBuilder.Entity("SimpleModule.BackgroundJobs.Entities.JobProgress", b => + modelBuilder.Entity("SimpleModule.BackgroundJobs.Contracts.JobProgress", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -543,7 +470,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BackgroundJobs_JobProgress", (string)null); }); - modelBuilder.Entity("SimpleModule.BackgroundJobs.Entities.JobQueueEntryEntity", b => + modelBuilder.Entity("SimpleModule.BackgroundJobs.Contracts.JobQueueEntryEntity", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -608,193 +535,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BackgroundJobs_JobQueueEntries", (string)null); }); - modelBuilder.Entity("SimpleModule.Chat.Contracts.ChatMessage", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("Content") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("ConversationId") - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("INTEGER"); - - b.Property("Role") - .HasColumnType("INTEGER"); - - b.Property("UpdatedAt") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("ConversationId", "CreatedAt"); - - b.ToTable("Chat_ChatMessages", (string)null); - }); - - modelBuilder.Entity("SimpleModule.Chat.Contracts.Conversation", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("AgentName") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("INTEGER"); - - b.Property("Pinned") - .HasColumnType("INTEGER"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("INTEGER"); - - b.Property("UserId") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId", "UpdatedAt"); - - b.ToTable("Chat_Conversations", (string)null); - }); - - modelBuilder.Entity("SimpleModule.Datasets.Entities.Dataset", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("BboxMaxX") - .HasColumnType("REAL"); - - b.Property("BboxMaxY") - .HasColumnType("REAL"); - - b.Property("BboxMinX") - .HasColumnType("REAL"); - - b.Property("BboxMinY") - .HasColumnType("REAL"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("ContentHash") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CreatedBy") - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("DeletedBy") - .HasColumnType("TEXT"); - - b.Property("ErrorMessage") - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("FeatureCount") - .HasColumnType("INTEGER"); - - b.Property("Format") - .HasColumnType("INTEGER"); - - b.Property("IsDeleted") - .HasColumnType("INTEGER"); - - b.Property("MetadataJson") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedPath") - .HasMaxLength(1024) - .HasColumnType("TEXT"); - - b.Property("OriginalFileName") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("ProcessedAt") - .HasColumnType("TEXT"); - - b.Property("SizeBytes") - .HasColumnType("INTEGER"); - - b.Property("SourceSrid") - .HasColumnType("INTEGER"); - - b.Property("Srid") - .HasColumnType("INTEGER"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UpdatedBy") - .HasColumnType("TEXT"); - - b.Property("Version") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("ContentHash"); - - b.HasIndex("Format"); - - b.HasIndex("Status"); - - b.HasIndex("IsDeleted", "CreatedAt"); - - b.HasIndex("BboxMinX", "BboxMaxX", "BboxMinY", "BboxMaxY"); - - b.ToTable("Datasets_Datasets", (string)null); - }); - modelBuilder.Entity("SimpleModule.Email.Contracts.EmailMessage", b => { b.Property("Id") @@ -895,678 +635,59 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("TEXT"); - b.Property("IsHtml") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Slug") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Subject") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Slug") - .IsUnique(); - - b.ToTable("Email_EmailTemplates", (string)null); - }); - - modelBuilder.Entity("SimpleModule.FeatureFlags.Entities.FeatureFlagEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .IsRequired() - .HasMaxLength(40) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("IsDeprecated") - .HasColumnType("INTEGER"); - - b.Property("IsEnabled") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("FeatureFlags_FeatureFlags", (string)null); - }); - - modelBuilder.Entity("SimpleModule.FeatureFlags.Entities.FeatureFlagOverrideEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .IsRequired() - .HasMaxLength(40) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("FlagName") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("IsEnabled") - .HasColumnType("INTEGER"); - - b.Property("OverrideType") - .HasColumnType("INTEGER"); - - b.Property("OverrideValue") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("FlagName", "OverrideType", "OverrideValue") - .IsUnique(); - - b.ToTable("FeatureFlags_FeatureFlagOverrides", (string)null); - }); - - modelBuilder.Entity("SimpleModule.FileStorage.Contracts.StoredFile", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("ContentType") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CreatedByUserId") - .HasMaxLength(450) - .HasColumnType("TEXT"); - - b.Property("FileName") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("Folder") - .HasMaxLength(1024) - .HasColumnType("TEXT"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("StoragePath") - .IsRequired() - .HasMaxLength(1024) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CreatedByUserId"); - - b.HasIndex("Folder"); - - b.HasIndex("Folder", "FileName") - .IsUnique(); - - b.ToTable("FileStorage_StoredFiles", (string)null); - }); - - modelBuilder.Entity("SimpleModule.Map.Contracts.Basemap", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("Attribution") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CreatedBy") - .HasColumnType("TEXT"); - - b.Property("Description") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("StyleUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("TEXT"); - - b.Property("ThumbnailUrl") - .HasMaxLength(2048) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UpdatedBy") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("Map_Basemaps", (string)null); - - b.HasData( - new - { - Id = new Guid("22222222-2222-2222-2222-000000000001"), - Attribution = "MapLibre", - ConcurrencyStamp = "seed-basemap-demotiles", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Official MapLibre demo vector style. Free for development.", - Name = "MapLibre Demotiles", - StyleUrl = "https://demotiles.maplibre.org/style.json", - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("22222222-2222-2222-2222-000000000002"), - Attribution = "© OpenStreetMap contributors, OpenFreeMap", - ConcurrencyStamp = "seed-basemap-openfreemap-liberty", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "OpenFreeMap free vector basemap, Liberty style.", - Name = "OpenFreeMap Liberty", - StyleUrl = "https://tiles.openfreemap.org/styles/liberty", - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("22222222-2222-2222-2222-000000000003"), - Attribution = "© OpenStreetMap contributors, OpenFreeMap", - ConcurrencyStamp = "seed-basemap-openfreemap-positron", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "OpenFreeMap free vector basemap, light Positron style.", - Name = "OpenFreeMap Positron", - StyleUrl = "https://tiles.openfreemap.org/styles/positron", - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("22222222-2222-2222-2222-000000000004"), - Attribution = "© OpenStreetMap contributors, OpenFreeMap", - ConcurrencyStamp = "seed-basemap-openfreemap-bright", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "OpenFreeMap free vector basemap, Bright style.", - Name = "OpenFreeMap Bright", - StyleUrl = "https://tiles.openfreemap.org/styles/bright", - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = new Guid("22222222-2222-2222-2222-000000000005"), - Attribution = "© OpenStreetMap contributors, VersaTiles", - ConcurrencyStamp = "seed-basemap-versatiles-colorful", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "VersaTiles free OSM-based vector basemap, Colorful style.", - Name = "Versatiles Colorful", - StyleUrl = "https://tiles.versatiles.org/assets/styles/colorful/style.json", - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }); - }); - - modelBuilder.Entity("SimpleModule.Map.Contracts.LayerSource", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("Attribution") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("Bounds") - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CreatedBy") - .HasColumnType("TEXT"); - - b.Property("Description") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("MaxZoom") - .HasColumnType("INTEGER"); - - b.Property("Metadata") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("MinZoom") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UpdatedBy") - .HasColumnType("TEXT"); - - b.Property("Url") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("Map_LayerSources", (string)null); - - b.HasData( - new - { - Id = new Guid("11111111-1111-1111-1111-000000000001"), - Attribution = "© OpenStreetMap contributors", - Bounds = "[-180,-85,180,85]", - ConcurrencyStamp = "seed-osm-xyz", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Standard OSM raster tiles. Free for low-volume use; respect the OSMF tile usage policy.", - MaxZoom = 19, - Metadata = "{}", - MinZoom = 0, - Name = "OpenStreetMap (raster tiles)", - Type = 3, - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Url = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" - }, - new - { - Id = new Guid("11111111-1111-1111-1111-000000000002"), - Attribution = "© OpenStreetMap contributors, terrestris", - Bounds = "[-180,-85,180,85]", - ConcurrencyStamp = "seed-terrestris-wms", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Public WMS by terrestris. Used in the official MapLibre 'Add a WMS source' example.", - Metadata = "{\"layers\":\"OSM-WMS\",\"format\":\"image/png\",\"version\":\"1.1.1\",\"crs\":\"EPSG:3857\",\"transparent\":\"true\"}", - Name = "terrestris OSM-WMS", - Type = 0, - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Url = "https://ows.terrestris.de/osm/service" - }, - new - { - Id = new Guid("11111111-1111-1111-1111-000000000003"), - Attribution = "© OpenStreetMap contributors, terrestris", - Bounds = "[-180,-85,180,85]", - ConcurrencyStamp = "seed-terrestris-topo", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "terrestris topographic WMS overlay layer (transparent).", - Metadata = "{\"layers\":\"TOPO-WMS,OSM-Overlay-WMS\",\"format\":\"image/png\",\"version\":\"1.1.1\",\"crs\":\"EPSG:3857\",\"transparent\":\"true\"}", - Name = "terrestris TOPO-WMS", - Type = 0, - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Url = "https://ows.terrestris.de/osm/service" - }, - new - { - Id = new Guid("11111111-1111-1111-1111-000000000004"), - Attribution = "MapLibre", - Bounds = "[-180,-85,180,85]", - ConcurrencyStamp = "seed-maplibre-demotiles", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Official MapLibre demo MVT vector tileset. Free for development.", - MaxZoom = 14, - Metadata = "{\"sourceLayer\":\"countries\"}", - MinZoom = 0, - Name = "MapLibre demotiles (vector)", - Type = 4, - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Url = "https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.pbf" - }, - new - { - Id = new Guid("11111111-1111-1111-1111-000000000005"), - Attribution = "© OpenStreetMap contributors, Protomaps", - Bounds = "[11.154,43.727,11.328,43.823]", - ConcurrencyStamp = "seed-protomaps-firenze", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Public PMTiles vector archive of Florence (ODbL). Used in the MapLibre PMTiles example.", - Metadata = "{\"tileType\":\"vector\",\"sourceLayer\":\"landuse\"}", - Name = "Protomaps Firenze (PMTiles)", - Type = 5, - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Url = "https://pmtiles.io/protomaps(vector)ODbL_firenze.pmtiles" - }, - new - { - Id = new Guid("11111111-1111-1111-1111-000000000006"), - Attribution = "Geomatico", - Bounds = "[-180,-85,180,85]", - ConcurrencyStamp = "seed-geomatico-cog", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Public Cloud-Optimized GeoTIFF demo from the maplibre-cog-protocol sample viewer.", - Metadata = "{}", - Name = "Geomatico kriging COG (demo)", - Type = 6, - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Url = "https://labs.geomatico.es/maplibre-cog-protocol/data/kriging.tif" - }, - new - { - Id = new Guid("11111111-1111-1111-1111-000000000007"), - Attribution = "USGS / MapLibre demo", - Bounds = "[-180,-85,180,85]", - ConcurrencyStamp = "seed-maplibre-earthquakes", - CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Description = "Small public GeoJSON FeatureCollection from the MapLibre demo assets.", - Metadata = "{\"color\":\"#ef4444\"}", - Name = "MapLibre demotiles point sample (GeoJSON)", - Type = 7, - UpdatedAt = new DateTimeOffset(new DateTime(2025, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Url = "https://maplibre.org/maplibre-gl-js/docs/assets/significant-earthquakes-2015.geojson" - }); - }); - - modelBuilder.Entity("SimpleModule.Map.Contracts.SavedMap", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("BaseStyleUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("TEXT"); - - b.Property("Bearing") - .HasColumnType("REAL"); - - b.Property("CenterLat") - .HasColumnType("REAL"); - - b.Property("CenterLng") - .HasColumnType("REAL"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CreatedBy") - .HasColumnType("TEXT"); - - b.Property("Description") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Pitch") - .HasColumnType("REAL"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UpdatedBy") - .HasColumnType("TEXT"); - - b.Property("Zoom") - .HasColumnType("REAL"); - - b.HasKey("Id"); - - b.ToTable("Map_SavedMaps", (string)null); - }); - - modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CreatedBy") - .HasColumnType("TEXT"); - - b.Property("Total") - .HasColumnType("decimal(18,2)"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UpdatedBy") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("Orders_Orders", (string)null); - }); - - modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => - { - b.Property("OrderId") - .HasColumnType("INTEGER"); - - b.Property("ProductId") - .HasColumnType("INTEGER"); - - b.Property("Quantity") - .HasColumnType("INTEGER"); - - b.HasKey("OrderId", "ProductId"); - - b.ToTable("Orders_OrderItems", (string)null); - }); - - modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.Page", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("Content") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CreatedBy") - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("DeletedBy") - .HasColumnType("TEXT"); - - b.Property("DraftContent") - .HasColumnType("TEXT"); - - b.Property("IsDeleted") - .HasColumnType("INTEGER"); - - b.Property("IsPublished") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false); - - b.Property("MetaDescription") - .HasMaxLength(300) - .HasColumnType("TEXT"); - - b.Property("MetaKeywords") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("OgImage") - .HasMaxLength(500) - .HasColumnType("TEXT"); - - b.Property("Order") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0); - - b.Property("Slug") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.Property("UpdatedBy") - .HasColumnType("TEXT"); - - b.Property("Version") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("IsPublished"); - - b.HasIndex("Slug") - .IsUnique(); - - b.HasIndex("IsDeleted", "DeletedAt"); - - b.ToTable("PageBuilder_Pages", (string)null); - }); - - modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTag", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() + b.Property("IsHtml") .HasColumnType("INTEGER"); b.Property("Name") .IsRequired() - .HasMaxLength(100) + .HasMaxLength(200) .HasColumnType("TEXT"); - b.Property("PageId") - .HasColumnType("INTEGER"); + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("Name") + b.HasIndex("Slug") .IsUnique(); - b.HasIndex("PageId"); - - b.ToTable("PageBuilder_Tags", (string)null); + b.ToTable("Email_EmailTemplates", (string)null); }); - modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTemplate", b => + modelBuilder.Entity("SimpleModule.FeatureFlags.Contracts.FeatureFlagEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); b.Property("ConcurrencyStamp") + .IsConcurrencyToken() .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("Content") - .IsRequired() + .HasMaxLength(40) .HasColumnType("TEXT"); b.Property("CreatedAt") .HasColumnType("TEXT"); + b.Property("IsDeprecated") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() - .HasMaxLength(200) + .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("UpdatedAt") @@ -1577,36 +698,52 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Name") .IsUnique(); - b.ToTable("PageBuilder_Templates", (string)null); + b.ToTable("FeatureFlags_FeatureFlags", (string)null); }); - modelBuilder.Entity("SimpleModule.Permissions.Entities.RolePermission", b => + modelBuilder.Entity("SimpleModule.FeatureFlags.Contracts.FeatureFlagOverrideEntity", b => { - b.Property("RoleId") + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) .HasColumnType("TEXT"); - b.Property("Permission") + b.Property("CreatedAt") .HasColumnType("TEXT"); - b.HasKey("RoleId", "Permission"); + b.Property("FlagName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); - b.ToTable("Permissions_RolePermissions", (string)null); - }); + b.Property("IsEnabled") + .HasColumnType("INTEGER"); - modelBuilder.Entity("SimpleModule.Permissions.Entities.UserPermission", b => - { - b.Property("UserId") + b.Property("OverrideType") + .HasColumnType("INTEGER"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT"); - b.Property("Permission") + b.Property("UpdatedAt") .HasColumnType("TEXT"); - b.HasKey("UserId", "Permission"); + b.HasKey("Id"); - b.ToTable("Permissions_UserPermissions", (string)null); + b.HasIndex("FlagName", "OverrideType", "OverrideValue") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlagOverrides", (string)null); }); - modelBuilder.Entity("SimpleModule.Products.Contracts.Product", b => + modelBuilder.Entity("SimpleModule.FileStorage.Contracts.StoredFile", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1617,161 +754,129 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(64) .HasColumnType("TEXT"); + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + b.Property("CreatedAt") .HasColumnType("TEXT"); - b.Property("Name") + b.Property("CreatedByUserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("FileName") .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Folder") + .HasMaxLength(1024) .HasColumnType("TEXT"); - b.Property("Price") - .HasColumnType("decimal(18,2)"); + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); b.Property("UpdatedAt") .HasColumnType("TEXT"); b.HasKey("Id"); - b.ToTable("Products_Products", (string)null); + b.HasIndex("CreatedByUserId"); - b.HasData( - new - { - Id = 1, - ConcurrencyStamp = "", - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Name = "Fantastic Rubber Shoes", - Price = 991.68m, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = 2, - ConcurrencyStamp = "", - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Name = "Fantastic Rubber Bacon", - Price = 446.22m, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = 3, - ConcurrencyStamp = "", - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Name = "Fantastic Concrete Bike", - Price = 660.12m, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = 4, - ConcurrencyStamp = "", - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Name = "Handcrafted Concrete Keyboard", - Price = 633.67m, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = 5, - ConcurrencyStamp = "", - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Name = "Intelligent Frozen Mouse", - Price = 674.30m, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = 6, - ConcurrencyStamp = "", - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Name = "Sleek Soft Hat", - Price = 851.63m, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = 7, - ConcurrencyStamp = "", - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Name = "Practical Fresh Bike", - Price = 417.48m, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = 8, - ConcurrencyStamp = "", - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Name = "Handmade Steel Ball", - Price = 975.56m, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = 9, - ConcurrencyStamp = "", - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Name = "Ergonomic Fresh Pants", - Price = 928.09m, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }, - new - { - Id = 10, - ConcurrencyStamp = "", - CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - Name = "Licensed Steel Sausages", - Price = 592.60m, - UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) - }); + b.HasIndex("Folder"); + + b.HasIndex("Folder", "FileName") + .IsUnique(); + + b.ToTable("FileStorage_StoredFiles", (string)null); }); - modelBuilder.Entity("SimpleModule.Rag.StructuredRag.Data.CachedStructuredKnowledge", b => + modelBuilder.Entity("SimpleModule.Notifications.Contracts.Notification", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("CollectionName") + b.Property("Body") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Channel") .IsRequired() - .HasMaxLength(256) + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsRequired() + .HasMaxLength(64) .HasColumnType("TEXT"); b.Property("CreatedAt") .HasColumnType("TEXT"); - b.Property("DocumentHash") + b.Property("DataJson") .IsRequired() - .HasMaxLength(64) .HasColumnType("TEXT"); - b.Property("ExpiresAt") + b.Property("ReadAt") .HasColumnType("TEXT"); - b.Property("HitCount") - .HasColumnType("INTEGER"); + b.Property("Title") + .HasMaxLength(500) + .HasColumnType("TEXT"); - b.Property("SourceTitle") + b.Property("Type") .IsRequired() - .HasMaxLength(512) + .HasMaxLength(200) .HasColumnType("TEXT"); - b.Property("StructureType") - .HasColumnType("INTEGER"); + b.Property("UpdatedAt") + .HasColumnType("TEXT"); - b.Property("StructuredContent") + b.Property("UserId") .IsRequired() + .HasMaxLength(450) .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("ExpiresAt"); + b.HasIndex("UserId", "ReadAt"); - b.HasIndex("CollectionName", "DocumentHash", "StructureType") - .IsUnique(); + b.HasIndex("UserId", "CreatedAt", "Id"); + + b.ToTable("Notifications_Notifications", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Contracts.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); - b.ToTable("Rag_CachedStructuredKnowledge", (string)null); + b.HasKey("RoleId", "Permission"); + + b.ToTable("Permissions_RolePermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Contracts.UserPermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Permission"); + + b.ToTable("Permissions_UserPermissions", (string)null); }); modelBuilder.Entity("SimpleModule.RateLimiting.Contracts.RateLimitRule", b => @@ -1842,7 +947,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RateLimiting_Rules", (string)null); }); - modelBuilder.Entity("SimpleModule.Settings.Entities.PublicMenuItemEntity", b => + modelBuilder.Entity("SimpleModule.Settings.Contracts.PublicMenuItemEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1903,7 +1008,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Settings_PublicMenuItems", (string)null); }); - modelBuilder.Entity("SimpleModule.Settings.Entities.SettingEntity", b => + modelBuilder.Entity("SimpleModule.Settings.Contracts.SettingEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1943,7 +1048,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Settings_Settings", (string)null); }); - modelBuilder.Entity("SimpleModule.Tenants.Entities.TenantEntity", b => + modelBuilder.Entity("SimpleModule.Tenants.Contracts.TenantEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -2038,7 +1143,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("SimpleModule.Tenants.Entities.TenantHostEntity", b => + modelBuilder.Entity("SimpleModule.Tenants.Contracts.TenantHostEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -2291,101 +1396,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Authorization"); }); - modelBuilder.Entity("SimpleModule.Chat.Contracts.ChatMessage", b => - { - b.HasOne("SimpleModule.Chat.Contracts.Conversation", null) - .WithMany("Messages") - .HasForeignKey("ConversationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("SimpleModule.Map.Contracts.SavedMap", b => - { - b.OwnsMany("SimpleModule.Map.Contracts.MapBasemap", "Basemaps", b1 => - { - b1.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b1.Property("BasemapId") - .HasColumnType("TEXT"); - - b1.Property("Order") - .HasColumnType("INTEGER"); - - b1.Property("SavedMapId") - .HasColumnType("TEXT"); - - b1.HasKey("Id"); - - b1.HasIndex("SavedMapId"); - - b1.ToTable("Map_MapBasemap", (string)null); - - b1.WithOwner() - .HasForeignKey("SavedMapId"); - }); - - b.OwnsMany("SimpleModule.Map.Contracts.MapLayer", "Layers", b1 => - { - b1.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b1.Property("LayerSourceId") - .HasColumnType("TEXT"); - - b1.Property("Opacity") - .HasColumnType("REAL"); - - b1.Property("Order") - .HasColumnType("INTEGER"); - - b1.Property("SavedMapId") - .HasColumnType("TEXT"); - - b1.Property("StyleOverrides") - .IsRequired() - .HasColumnType("TEXT"); - - b1.Property("Visible") - .HasColumnType("INTEGER"); - - b1.HasKey("Id"); - - b1.HasIndex("SavedMapId"); - - b1.ToTable("Map_MapLayer", (string)null); - - b1.WithOwner() - .HasForeignKey("SavedMapId"); - }); - - b.Navigation("Basemaps"); - - b.Navigation("Layers"); - }); - - modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => - { - b.HasOne("SimpleModule.Orders.Contracts.Order", null) - .WithMany("Items") - .HasForeignKey("OrderId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTag", b => + modelBuilder.Entity("SimpleModule.Settings.Contracts.PublicMenuItemEntity", b => { - b.HasOne("SimpleModule.PageBuilder.Contracts.Page", null) - .WithMany("Tags") - .HasForeignKey("PageId"); - }); - - modelBuilder.Entity("SimpleModule.Settings.Entities.PublicMenuItemEntity", b => - { - b.HasOne("SimpleModule.Settings.Entities.PublicMenuItemEntity", "Parent") + b.HasOne("SimpleModule.Settings.Contracts.PublicMenuItemEntity", "Parent") .WithMany("Children") .HasForeignKey("ParentId") .OnDelete(DeleteBehavior.Cascade); @@ -2393,9 +1406,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Parent"); }); - modelBuilder.Entity("SimpleModule.Tenants.Entities.TenantHostEntity", b => + modelBuilder.Entity("SimpleModule.Tenants.Contracts.TenantHostEntity", b => { - b.HasOne("SimpleModule.Tenants.Entities.TenantEntity", "Tenant") + b.HasOne("SimpleModule.Tenants.Contracts.TenantEntity", "Tenant") .WithMany("Hosts") .HasForeignKey("TenantId") .OnDelete(DeleteBehavior.Cascade) @@ -2416,27 +1429,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Tokens"); }); - modelBuilder.Entity("SimpleModule.Chat.Contracts.Conversation", b => - { - b.Navigation("Messages"); - }); - - modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => - { - b.Navigation("Items"); - }); - - modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.Page", b => - { - b.Navigation("Tags"); - }); - - modelBuilder.Entity("SimpleModule.Settings.Entities.PublicMenuItemEntity", b => + modelBuilder.Entity("SimpleModule.Settings.Contracts.PublicMenuItemEntity", b => { b.Navigation("Children"); }); - modelBuilder.Entity("SimpleModule.Tenants.Entities.TenantEntity", b => + modelBuilder.Entity("SimpleModule.Tenants.Contracts.TenantEntity", b => { b.Navigation("Hosts"); }); diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index de0b36b4..c861dc9c 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -35,6 +35,7 @@ + diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Databases.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Databases.cs index 4ad01452..facb1899 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Databases.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Databases.cs @@ -9,6 +9,7 @@ using SimpleModule.FeatureFlags; using SimpleModule.FileStorage; using SimpleModule.Host; +using SimpleModule.Notifications; using SimpleModule.OpenIddict; using SimpleModule.Permissions; using SimpleModule.RateLimiting; @@ -48,6 +49,7 @@ private void EnsureModuleDatabasesCreated() EnsureTablesCreated(sp); EnsureTablesCreated(sp); EnsureTablesCreated(sp); + EnsureTablesCreated(sp); EnsureTablesCreated(sp); } diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs index b784cf78..a4f6af3c 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs @@ -11,6 +11,7 @@ using SimpleModule.FeatureFlags; using SimpleModule.FileStorage; using SimpleModule.Host; +using SimpleModule.Notifications; using SimpleModule.OpenIddict; using SimpleModule.OpenIddict.Contracts; using SimpleModule.Permissions; @@ -74,6 +75,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ReplaceDbContext(services); ReplaceDbContext(services); ReplaceDbContext(services); + ReplaceDbContext(services); ReplaceDbContext(services, useOpenIddict: true); // Remove hosted seed services — they need real DB tables that