diff --git a/CLAUDE.md b/CLAUDE.md index 8fd852e..1f3487e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md — Eternelle Modular monolith backend in **.NET 10**, Clean Architecture + full DDD. -Database design: `docs/database-design.md`. +System architecture: `docs/architecture.md`. Module conventions (authoritative): `docs/module-conventions.md`. Per-module reference docs: `docs/modules/` (Weddings, Users, Catalog, Tenancy). diff --git a/Eternelle.slnx b/Eternelle.slnx index 56937f5..838fa31 100644 --- a/Eternelle.slnx +++ b/Eternelle.slnx @@ -1,6 +1,6 @@ - + @@ -14,14 +14,18 @@ - + + + + + @@ -46,6 +50,13 @@ + + + + + + + @@ -60,7 +71,7 @@ - + diff --git a/README.md b/README.md index eff2bbd..b52ebea 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Backend for **Eternelle** — a Philippine wedding platform that lets couples bu ## Modules -Four modules are implemented; two are planned. Each is a five-project vertical slice and owns its own PostgreSQL schema. See [`docs/modules/`](docs/modules/) for per-module reference docs and [`docs/database-design.md`](docs/database-design.md) for the cross-module data model. +Four modules are implemented; two are planned. Each is a five-project vertical slice and owns its own PostgreSQL schema. See [`docs/modules/`](docs/modules/) for per-module reference docs and [`docs/architecture.md`](docs/architecture.md) for the system architecture and cross-module data model. | Module | Schema | Status | Summary | |---|---|---|---| diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f34b07e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,373 @@ +# Eternelle — System Architecture + +Eternelle is a multi-tenant wedding-website platform built as a **modular monolith** in .NET 10 +(Clean Architecture + DDD). This document describes how the system fits together as a whole: its +runtime topology, the shared kernel every module builds on, how a request flows synchronously, +how modules communicate asynchronously, the consolidated data model, the cross-cutting concerns, +and the durable decisions behind the design. + +## What this document is (and is not) + +This is the **system-level** view — the glue between modules and the whole-system picture. It does +not repeat material owned by other docs. Use the map below to find the right source. + +| Question | Read | +|---|---| +| How is the system wired end-to-end? | **This document** | +| How do I write code in a module (patterns, invariants, workflow)? | [`CLAUDE.md`](../CLAUDE.md) | +| What are the module conventions (projects, references, naming, VOs)? | [`docs/module-conventions.md`](module-conventions.md) | +| What does a specific module contain (aggregates, endpoints, schema)? | [`docs/modules/`](modules/) | +| What is the forward-looking design for an unbuilt module? | [`docs/superpowers/specs/`](superpowers/specs/) | +| How does a specific feature work? | [`feature-gift-contributions.md`](feature-gift-contributions.md), [`feature-guest-photo-upload.md`](feature-guest-photo-upload.md) | + +## Keeping this document current + +This document tracks `main`: it describes **shipped** modules only. When a module merges to `main`, +update three places here — the module roster (§1), the consolidated ER diagram (§6), and the +integration-event map (§6). Forward-looking design stays in `docs/superpowers/specs/` until it ships. + +--- + +## 1. System context & runtime topology + +One ASP.NET Core host (`Eternelle.Api`) runs all modules in-process against one PostgreSQL database, +with each module owning a private schema. Shipped modules: + +| Module | Schema | Responsibility | +|---|---|---| +| Weddings | `wedding` | All couple/wedding content for a tenant's site | +| Users | `users` | Shadow user profile + RBAC (Keycloak owns credentials) | +| Catalog | `catalog` | Template & theme registry (mostly static reference data) | +| Tenancy | `tenancy` | Tenant lifecycle, template/theme assignment, section ordering, publishing | + +> RSVP (`rsvp`) and Guestbook (`guestbook`) are planned — see [`docs/superpowers/specs/`](superpowers/specs/). + +### External dependencies + +| Dependency | Role | Required | +|---|---|---| +| PostgreSQL 17 | System of record; one database, one schema per module | Yes | +| Keycloak | Identity provider — owns credentials, OAuth federation, JWT issuance | Yes (auth) | +| Redis | Distributed cache + guest-photo upload-slot store | Optional in dev — falls back to in-memory cache / stub slot store | +| MinIO / S3 | Guest-photo (SnapShare) object storage | Yes for photo upload | +| MassTransit (in-memory transport) | In-process integration-event bus | Yes — **no external broker**; reliability comes from the DB outbox/inbox | + +```mermaid +flowchart TB + client["Browser / API client"] -->|HTTPS + JWT| api["Eternelle.Api (modular monolith)"] + subgraph host["In-process modules"] + weddings["Weddings"] + users["Users"] + catalog["Catalog"] + tenancy["Tenancy"] + end + api --> host + host -->|EF Core write / Dapper read| pg[("PostgreSQL 17
schema per module")] + host -->|cache + upload slots| redis[("Redis (optional)")] + host -->|guest photos| minio[("MinIO / S3")] + api -->|JWT validation via JWKS| kc["Keycloak"] + host -. in-process integration events .-> host +``` + +--- + +## 2. Solution structure + +```text +src/ +├── API/Eternelle.Api/ ← Host: Program.cs, middleware, OpenTelemetry, migrations, config +├── Common/ ← Shared kernel (Domain, Application, Infrastructure, Presentation) +└── Modules/{Name}/ ← One folder per bounded context, each a five-project vertical slice +``` + +Each module is the same five-project slice — `Domain`, `Application`, `Infrastructure`, +`Presentation`, `IntegrationEvents`. The host references each module's `Infrastructure` and +`Presentation` to register it; modules never reference each other except through +`{X}.IntegrationEvents`. + +For the project layout, the ProjectReference graph (the hard dependency rules), naming, and the +module registration template, see [`docs/module-conventions.md`](module-conventions.md). This +document does not restate those rules — it covers what they produce at runtime (§3–§5). + +--- + +## 3. The shared kernel (`Common.*`) + +Every module builds on four shared projects. This is what a module gets "for free" and the contracts +it implements. + +### `Common.Domain` + +| Contract | Purpose | +|---|---| +| `Entity` | Aggregate-root base; buffers domain events (`Raise`, `DomainEvents`, `ClearDomainEvents`) | +| `Result` / `Result` | Outcome of every command/query handler — success or `Error` | +| `Error` / `ErrorType` | Typed failures (`NotFound`, `Problem`, `Conflict`, `Failure`, `Validation`) | +| `DomainEvent` / `IDomainEvent` | Base for domain events (carries `Id`, `OccurredOnUtc`) | +| `People.*`, `Text.*` | Shared value objects (`PersonName`, `Email`, `PhoneNumber`, `RichDescription`, …) lifted to Common when a second module needs them | + +### `Common.Application` + +| Contract | Purpose | +|---|---| +| `ICommand` / `ICommand` / `IQuery` + handlers | MediatR message contracts | +| `IEventBus`, `IIntegrationEvent`, `IntegrationEvent` | Publishing integration events across modules | +| `ExceptionHandling` / `RequestLogging` / `Validation` pipeline behaviors | Cross-cutting MediatR pipeline (see §4) | +| `IPermissionService` | Resolves a caller's permission codes (implemented by the Users module) | +| `IUnitOfWork`, `IDbConnectionFactory` | EF write boundary; Dapper read connections | +| `ICacheService`, `IDateTimeProvider` | Caching abstraction; testable clock | +| `ClaimsPrincipalExtensions`, `CustomClaims` | Reading identity / permission claims off the JWT | + +### `Common.Infrastructure` + +| Contract | Purpose | +|---|---| +| `AuthenticationExtensions`, `JwtBearerConfigureOptions` | Keycloak JWT bearer setup (JWKS validation) | +| `PermissionAuthorizationHandler`, `PermissionAuthorizationPolicyProvider`, `CustomClaimsTransformation` | Permission-based authorization | +| `Outbox/*`, `Inbox/*`, `InsertOutboxMessagesInterceptor` | Transactional messaging (see §5) | +| `EventBus` | MassTransit-backed `IEventBus` | +| `DbConnectionFactory`, `GenericArrayHandler` | Dapper read path + Npgsql array type handling | +| `SerializerSettings`, `DateTimeProvider` | JSON serialization; system clock | + +### `Common.Presentation` + +| Contract | Purpose | +|---|---| +| `IEndpoint`, `EndpointExtensions` | Minimal-API endpoint registration (assembly-scanned) | +| `ApiResults`, `ResultExtensions` | Maps `Result` / `Error` to RFC-7807 problem responses | + +--- + +## 4. Synchronous request lifecycle + +An authenticated write flows through a fixed middleware order (from `Program.cs`): + +`UseLogContextTraceLogging` → `UseSerilogRequestLogging` → `UseExceptionHandler` → +`UseAuthentication` → `UseAuthorization` → `UseRateLimiter` → endpoint. + +The endpoint (`IEndpoint`) builds a command and sends it via the MediatR `ISender`. Every request +passes through the MediatR pipeline, outer→inner: + +`ExceptionHandlingPipelineBehavior` → `RequestLoggingPipelineBehavior` → `ValidationPipelineBehavior` +→ handler. + +The handler returns `Result` / `Result`; the endpoint maps it with `Result.Match(onSuccess, ApiResults.Problem)` +(success → 2xx, `Error` → RFC-7807 problem response). Business-rule failures are `Error` values, +never exceptions. + +**Authorization** is permission-based: Keycloak issues the JWT; `JwtBearer` validates it against the +Keycloak JWKS; `CustomClaimsTransformation` loads the caller's permission codes (via the Users +module's `PermissionService`, which implements `IPermissionService`); `RequireAuthorization("code")` +policies are resolved by `PermissionAuthorizationPolicyProvider` + `PermissionAuthorizationHandler`. + +```mermaid +sequenceDiagram + participant C as Client + participant MW as Middleware (authn/authz/rate-limit) + participant E as IEndpoint + participant P as MediatR pipeline + participant H as Command handler + participant DB as PostgreSQL + C->>MW: HTTP + JWT + MW->>MW: validate JWT (JWKS), load permissions, check policy + MW->>E: route to endpoint + E->>P: ISender.Send(command) + P->>P: Exception → Logging → Validation + P->>H: handle + H->>DB: EF write via IUnitOfWork + H-->>E: Result + E-->>C: Result.Match → 2xx / problem +``` + +--- + +## 5. Asynchronous messaging pipeline + +Modules never call each other synchronously. They communicate through reliable, eventually-consistent +integration events, using a transactional outbox/inbox so a published event can never be lost or +double-applied even though the transport is in-memory. + +Flow: + +1. An aggregate raises a **domain event** (`Entity.Raise`), embedding all payload it will need at + raise-time — handlers do **not** reload the aggregate. +2. `InsertOutboxMessagesInterceptor` serializes pending domain events into the module's + `{schema}.outbox_messages` table **in the same EF transaction** as the state change. +3. A **Quartz** `ProcessOutboxJob` per module reads unprocessed rows and dispatches each to its + `DomainEventHandler`. +4. The handler publishes an `IntegrationEvent` via `IEventBus` (MassTransit, in-memory transport), + directly from the domain-event payload. +5. A consuming module receives it, records it in `{schema}.inbox_messages` for idempotency, and runs + its `IntegrationEventHandler` (registered by scanning the Presentation assembly). + +Each schema therefore carries `outbox_messages` + `outbox_message_consumers`, and `inbox_messages` + +`inbox_message_consumers` where it consumes events (Catalog has outbox only — it publishes but does +not consume). This outbox/inbox seam is what makes a later extraction to a separate service +mechanical. + +The domain-event-handler coding pattern (embed payload, publish directly, no repository reload) is in +[`CLAUDE.md`](../CLAUDE.md#application-layer) — not repeated here. + +```mermaid +flowchart LR + A["Aggregate raises domain event"] --> I["InsertOutboxMessagesInterceptor
(same transaction)"] + I --> OB[("{schema}.outbox_messages")] + OB --> J["Quartz ProcessOutboxJob"] + J --> DEH["DomainEventHandler
publishes IntegrationEvent"] + DEH --> BUS["IEventBus / MassTransit (in-memory)"] + BUS --> IB[("{schema}.inbox_messages
idempotency")] + IB --> IEH["IntegrationEventHandler
(consuming module)"] +``` + +--- + +## 6. Cross-module data view + +One database, four shipped schemas. Per-table column detail lives in [`docs/modules/`](modules/); +this is the whole-system relationship picture and the rules that govern cross-schema access. + +```mermaid +erDiagram + users_users ||--o{ users_user_roles : has + users_roles ||--o{ users_user_roles : "granted to" + users_roles ||--o{ users_role_permissions : has + users_permissions ||--o{ users_role_permissions : "granted by" + + catalog_templates ||--o{ catalog_template_themes : has + catalog_templates ||--o{ catalog_template_supported_sections : supports + catalog_templates ||--o{ catalog_template_section_variants : "has variants" + + tenancy_tenants ||--o{ tenancy_tenant_section_entries : has + + wedding_profiles ||--o{ wedding_partners : has + wedding_profiles ||--o{ wedding_story_moments : has + wedding_profiles ||--o{ wedding_entourage_groups : has + wedding_entourage_groups ||--o{ wedding_entourage_members : contains + wedding_entourage_groups ||--o{ wedding_entourage_couples : contains + wedding_profiles ||--o{ wedding_gallery_images : has + wedding_profiles ||--o{ wedding_gift_options : has + wedding_profiles ||--|| wedding_dress_code_configs : has + wedding_dress_code_configs ||--o{ wedding_dress_code_colors : has + wedding_dress_code_configs ||--o{ wedding_dress_code_images : has + wedding_profiles ||--o{ wedding_ceremony_acts : has + wedding_profiles ||--o{ wedding_vendor_credits : has + wedding_profiles ||--o{ wedding_reminders : has + wedding_profiles ||--|| wedding_snap_share_configs : has + wedding_profiles ||--o{ wedding_guest_photos : has + + users_users ||--o{ tenancy_tenants : "owns (opaque ref)" + catalog_templates ||--o{ tenancy_tenants : "assigned (opaque ref)" + tenancy_tenants ||--|| wedding_profiles : "provisions (via event)" +``` + +> **Cross-schema relationships** (the last three edges above) are stored as opaque `Guid`s, never as +> database foreign keys, and are never resolved with a cross-schema `JOIN` in application code. Each +> module owns its schema; the connections are logical, realized through the integration-event map +> below. + +### Integration-event map + +This is the real cross-module contract. No module queries another module's schema; any data a module +needs from another it keeps as a **local copy**, kept consistent by these events. + +| Event | Published by | Consumed by | Effect on the consumer | +|---|---|---|---| +| `CatalogTemplateManifestChangedIntegrationEvent` | Catalog | Tenancy | Upserts a local `tenancy.catalog_templates` projection used to validate section/variant choices and compute the guest-upload limit — without touching the `catalog` schema | +| `TenantProvisionedIntegrationEvent` | Tenancy | Weddings | Auto-creates the `Wedding` aggregate for the new tenant | +| `TenantUploadLimitChangedIntegrationEvent` | Tenancy | Weddings | Syncs `Wedding.GuestPhotoUploadLimit` | +| `UserRegisteredIntegrationEvent`, `UserProfileUpdatedIntegrationEvent` | Users | (none yet) | — | +| `WeddingCreatedIntegrationEvent` | Weddings | RSVP | Creates a local `rsvp.weddings` projection — handled by `WeddingCreatedIntegrationEventHandler` in `RsvpModule` | + +The `tenancy.catalog_templates` table is the canonical example of the local-copy pattern: it is a +read-model projection of the Catalog manifest, not a foreign key into `catalog`. + +### Cross-cutting data conventions + +- Primary keys are app-generated **UUIDv7** via strongly-typed `record struct` IDs (no `gen_random_uuid()`). +- Mutable rows carry `created_at` / `updated_at` (`timestamptz`). +- Ordered child tables carry `display_order int` — order is never inferred from insert time. +- Closed vocabularies are stored as **`int` columns** (EF `HasConversion()`), not native PG enums. +- Every schema also has `outbox_messages` (+ `inbox_messages` where it consumes events). + +--- + +## 7. Cross-cutting concerns + +### Host composition & startup +`Program.cs` composes the host in order: `AddApplication([module application assemblies])` (MediatR + +behaviors + FluentValidation) → `AddInfrastructure(...)` (MassTransit, Quartz hosted service, +Npgsql/Dapper, Redis, OpenTelemetry) → `AddModuleConfiguration([...])` → health checks → per-module +`Add{Module}Module(configuration)`. In Development, `app.ApplyMigrations()` applies EF migrations on +startup. + +### Configuration & secrets +Each module loads `modules.{name}.json` + `modules.{name}.Development.json` (via +`AddModuleConfiguration`). Options are validated at startup with `ValidateOnStart`. Base files hold +only non-secret defaults; keys that must be environment-provided are **omitted** from the base file +(an empty string fails validation). Secrets come from environment variables in non-dev environments +(e.g. `Weddings__PhotoStorage__AccessKey`). + +### Persistence +One EF Core `DbContext` and one schema per module. Value objects and strongly-typed IDs map via EF +value converters. Writes go through EF + `IUnitOfWork`; reads use Dapper via `IDbConnectionFactory`. +Migrations are per module. + +### Observability +Logging is Serilog (`UseSerilogRequestLogging` plus a trace-context middleware). Tracing is +OpenTelemetry exported via OTLP, instrumenting ASP.NET Core, HttpClient, Npgsql, and MassTransit. +Health checks (`/health`) cover PostgreSQL, Redis, and Keycloak. + +### Resilience +Redis is optional in development: if the connection fails at startup, the host falls back to an +in-memory distributed cache, and the Weddings module substitutes a `StubUploadSlotStore` for the +Redis-backed slot store. + +--- + +## 8. Architecture decisions + +Condensed records of the durable "why" behind the design. + +### Keycloak owns identity; the app keeps a shadow profile +**Context:** password hashing, OAuth federation, token rotation, and brute-force protection are +solved problems. **Decision:** Keycloak owns all credentials and federation; the `users.users` table +is a shadow profile keyed by the JWT `sub` (`identity_id`), provisioned just-in-time on first login. +**Consequence:** the app never stores passwords or provider tokens; `identity_id` is the only stable +cross-system identifier (never key on email). + +### RBAC / permissions live in the app DB, not Keycloak +**Context:** fine-grained permission codes change with business logic and must be queryable and +testable without a running Keycloak. **Decision:** Keycloak answers "who are you?"; the Users module +answers "what may you do?" via roles → permission codes resolved by `IPermissionService`. +**Consequence:** authorization evolves independently of realm configuration. + +### Modular monolith with a schema per module +**Context:** the domains are cohesive, but deploying them as microservices would be premature. +**Decision:** one process, one database, a private schema per module; cross-module access only via +`{X}.IntegrationEvents`. **Consequence:** strong boundaries today; mechanical extraction later. + +### No shared cross-schema PostgreSQL types +**Context:** a shared PG enum/type referenced across schemas couples those schemas. **Decision:** each +module stores its own `int` columns for closed vocabularies; shared values (e.g. tier) are mirrored +and validated at the service layer, with the C# enum as the single definition. **Consequence:** no +cross-schema coupling and no duplicated DB type to keep in sync. + +### Local data copies + integration events over synchronous cross-module calls +**Context:** synchronous cross-module reads would re-introduce coupling. **Decision:** each module +duplicates the few fields it needs and keeps them consistent via integration events (e.g. Tenancy's +`catalog_templates` projection). **Consequence:** modules are reasoned about independently; reads +never fan out across modules. + +### Transactional outbox/inbox for reliability +**Context:** the transport is in-memory, but events must not be lost or applied twice. **Decision:** +persist events to an outbox in the same transaction as the state change; consumers dedupe via an +inbox. **Consequence:** at-least-once delivery with idempotent handling, independent of the transport. + +### Strongly-typed UUIDv7 identifiers +**Context:** raw `Guid`s are easy to transpose and random UUIDs fragment indexes. **Decision:** +app-generated UUIDv7 wrapped in `readonly record struct` IDs per aggregate. **Consequence:** type-safe +IDs and time-ordered, index-friendly keys. + +> The RBAC baseline (seeded roles Admin/Couple/Guest and their permission codes) is documented in +> [`docs/modules/users.md`](modules/users.md). +``` diff --git a/docs/modules/README.md b/docs/modules/README.md index b17fc64..0cf1f9d 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -1,6 +1,6 @@ # Module Reference -Per-module reference documentation. For the conventions every module follows, see [`../module-conventions.md`](../module-conventions.md); for the cross-module data model, see [`../database-design.md`](../database-design.md). +Per-module reference documentation. For the conventions every module follows, see [`../module-conventions.md`](../module-conventions.md); for the system architecture and cross-module data model, see [`../architecture.md`](../architecture.md). | Module | Schema | Status | Doc | |---|---|---|---| diff --git a/src/API/Eternelle.Api/Eternelle.Api.csproj b/src/API/Eternelle.Api/Eternelle.Api.csproj index dc3f0de..897ee40 100644 --- a/src/API/Eternelle.Api/Eternelle.Api.csproj +++ b/src/API/Eternelle.Api/Eternelle.Api.csproj @@ -25,6 +25,8 @@ + +
diff --git a/src/API/Eternelle.Api/Extensions/MigrationExtensions.cs b/src/API/Eternelle.Api/Extensions/MigrationExtensions.cs index 357fdfd..00ec843 100644 --- a/src/API/Eternelle.Api/Extensions/MigrationExtensions.cs +++ b/src/API/Eternelle.Api/Extensions/MigrationExtensions.cs @@ -1,4 +1,5 @@ using Eternelle.Modules.Catalog.Infrastructure.Database; +using Eternelle.Modules.Rsvp.Infrastructure.Database; using Eternelle.Modules.Tenancy.Infrastructure.Database; using Eternelle.Modules.Users.Infrastructure.Database; using Eternelle.Modules.Weddings.Infrastructure.Database; @@ -16,6 +17,7 @@ public static void ApplyMigrations(this IApplicationBuilder app) ApplyMigration(scope); ApplyMigration(scope); ApplyMigration(scope); + ApplyMigration(scope); } private static void ApplyMigration(IServiceScope scope) diff --git a/src/API/Eternelle.Api/Program.cs b/src/API/Eternelle.Api/Program.cs index c4a29c2..44b2f4e 100644 --- a/src/API/Eternelle.Api/Program.cs +++ b/src/API/Eternelle.Api/Program.cs @@ -7,6 +7,7 @@ using Eternelle.Common.Infrastructure.Configuration; using Eternelle.Common.Presentation.Endpoints; using Eternelle.Modules.Catalog.Infrastructure; +using Eternelle.Modules.Rsvp.Infrastructure; using Eternelle.Modules.Tenancy.Infrastructure; using Eternelle.Modules.Users.Infrastructure; using Eternelle.Modules.Weddings.Infrastructure; @@ -29,7 +30,8 @@ Eternelle.Modules.Weddings.Application.AssemblyReference.Assembly, Eternelle.Modules.Users.Application.AssemblyReference.Assembly, Eternelle.Modules.Catalog.Application.AssemblyReference.Assembly, - Eternelle.Modules.Tenancy.Application.AssemblyReference.Assembly + Eternelle.Modules.Tenancy.Application.AssemblyReference.Assembly, + Eternelle.Modules.Rsvp.Application.AssemblyReference.Assembly ]); string databaseConnectionString = builder.Configuration.GetConnectionStringOrThrow("Database"); @@ -40,12 +42,13 @@ moduleConfigureConsumers: [ TenancyModule.ConfigureConsumers, - WeddingsModule.ConfigureConsumers + WeddingsModule.ConfigureConsumers, + RsvpModule.ConfigureConsumers ], databaseConnectionString: databaseConnectionString, redisConnectionString: redisConnectionString); -builder.Configuration.AddModuleConfiguration(["weddings", "users", "catalog", "tenancy"]); +builder.Configuration.AddModuleConfiguration(["weddings", "users", "catalog", "tenancy", "rsvp"]); Uri keyCloakHealthUrl = builder.Configuration.GetKeyCloakHealthUrl(); @@ -62,6 +65,8 @@ builder.Services.AddTenancyModule(builder.Configuration); +builder.Services.AddRsvpModule(builder.Configuration); + builder.Services.AddRateLimiter(options => { // IP-based fixed-window limiter for the public guest photo upload endpoint. @@ -76,6 +81,22 @@ QueueLimit = 0 })); + options.AddPolicy(Eternelle.Modules.Rsvp.Presentation.RateLimitingPolicies.RsvpLookup, ctx => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 + })); + + options.AddPolicy(Eternelle.Modules.Rsvp.Presentation.RateLimitingPolicies.RsvpSubmit, ctx => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 + })); + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; }); diff --git a/src/API/Eternelle.Api/appsettings.Development.json b/src/API/Eternelle.Api/appsettings.Development.json index 0e710be..0d8b616 100644 --- a/src/API/Eternelle.Api/appsettings.Development.json +++ b/src/API/Eternelle.Api/appsettings.Development.json @@ -26,7 +26,13 @@ "Eternelle.Modules.Weddings.Infrastructure.Outbox": "Warning", "Eternelle.Modules.Weddings.Infrastructure.Inbox": "Warning", "Eternelle.Modules.Users.Infrastructure.Outbox": "Warning", - "Eternelle.Modules.Users.Infrastructure.Inbox": "Warning" + "Eternelle.Modules.Users.Infrastructure.Inbox": "Warning", + "Eternelle.Modules.Catalog.Infrastructure.Outbox": "Warning", + "Eternelle.Modules.Catalog.Infrastructure.Inbox": "Warning", + "Eternelle.Modules.Tenancy.Infrastructure.Outbox": "Warning", + "Eternelle.Modules.Tenancy.Infrastructure.Inbox": "Warning", + "Eternelle.Modules.Rsvp.Infrastructure.Outbox": "Warning", + "Eternelle.Modules.Rsvp.Infrastructure.Inbox": "Warning" } }, "WriteTo": [ diff --git a/src/API/Eternelle.Api/modules.rsvp.Development.json b/src/API/Eternelle.Api/modules.rsvp.Development.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/API/Eternelle.Api/modules.rsvp.Development.json @@ -0,0 +1 @@ +{} diff --git a/src/API/Eternelle.Api/modules.rsvp.json b/src/API/Eternelle.Api/modules.rsvp.json new file mode 100644 index 0000000..92ca742 --- /dev/null +++ b/src/API/Eternelle.Api/modules.rsvp.json @@ -0,0 +1,6 @@ +{ + "Rsvp": { + "Outbox": { "IntervalInSeconds": 10, "BatchSize": 20 }, + "Inbox": { "IntervalInSeconds": 10, "BatchSize": 20 } + } +} diff --git a/src/Common/Eternelle.Common.Application/Data/IDbConnectionFactory.cs b/src/Common/Eternelle.Common.Application/Data/IDbConnectionFactory.cs index 9f09abd..6af6ff5 100644 --- a/src/Common/Eternelle.Common.Application/Data/IDbConnectionFactory.cs +++ b/src/Common/Eternelle.Common.Application/Data/IDbConnectionFactory.cs @@ -4,5 +4,5 @@ namespace Eternelle.Common.Application.Data; public interface IDbConnectionFactory { - ValueTask OpenConnectionAsync(); + ValueTask OpenConnectionAsync(CancellationToken cancellationToken = default); } diff --git a/src/Common/Eternelle.Common.Domain/AssemblyInfo.cs b/src/Common/Eternelle.Common.Domain/AssemblyInfo.cs new file mode 100644 index 0000000..9fe022d --- /dev/null +++ b/src/Common/Eternelle.Common.Domain/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Runtime.CompilerServices; + +// VO FromPersistence() factory methods are `internal` to keep the validation-bypassing +// constructor off public API. Each module's Infrastructure project needs visibility +// to drive EF Core value converters. +[assembly: InternalsVisibleTo("Eternelle.Modules.Weddings.Infrastructure")] +[assembly: InternalsVisibleTo("Eternelle.Modules.Rsvp.Infrastructure")] diff --git a/src/Common/Eternelle.Common.Domain/People/EmailAddress.cs b/src/Common/Eternelle.Common.Domain/People/EmailAddress.cs new file mode 100644 index 0000000..47c89b3 --- /dev/null +++ b/src/Common/Eternelle.Common.Domain/People/EmailAddress.cs @@ -0,0 +1,35 @@ +namespace Eternelle.Common.Domain.People; + +public sealed record EmailAddress +{ + public const int MaxLength = 254; // RFC 5321 envelope max + + private EmailAddress(string value) { Value = value; } + public string Value { get; } + + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(EmailAddressErrors.Empty); + } + + string trimmed = raw.Trim(); + if (trimmed.Length > MaxLength) + { + return Result.Failure(EmailAddressErrors.TooLong); + } + + int at = trimmed.IndexOf('@'); + if (at <= 0 || at != trimmed.LastIndexOf('@') || at == trimmed.Length - 1) + { + return Result.Failure(EmailAddressErrors.InvalidFormat); + } + + return Result.Success(new EmailAddress(trimmed)); + } + + public override string ToString() => Value; + + internal static EmailAddress FromPersistence(string value) => new(value); +} diff --git a/src/Common/Eternelle.Common.Domain/People/EmailAddressErrors.cs b/src/Common/Eternelle.Common.Domain/People/EmailAddressErrors.cs new file mode 100644 index 0000000..9ba3b7f --- /dev/null +++ b/src/Common/Eternelle.Common.Domain/People/EmailAddressErrors.cs @@ -0,0 +1,14 @@ +namespace Eternelle.Common.Domain.People; + +public static class EmailAddressErrors +{ + public static readonly Error Empty = + Error.Problem("EmailAddress.Empty", "Email is required."); + + public static readonly Error TooLong = + Error.Problem("EmailAddress.TooLong", + $"Email must not exceed {EmailAddress.MaxLength} characters."); + + public static readonly Error InvalidFormat = + Error.Problem("EmailAddress.InvalidFormat", "Email format is not valid."); +} diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonFirstName.cs b/src/Common/Eternelle.Common.Domain/People/PersonFirstName.cs similarity index 92% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonFirstName.cs rename to src/Common/Eternelle.Common.Domain/People/PersonFirstName.cs index d93b644..668bab4 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonFirstName.cs +++ b/src/Common/Eternelle.Common.Domain/People/PersonFirstName.cs @@ -1,6 +1,6 @@ -using Eternelle.Common.Domain; +using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.Shared; +namespace Eternelle.Common.Domain.People; /// /// First name of a partner. Paired with for partner records; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonFirstNameErrors.cs b/src/Common/Eternelle.Common.Domain/People/PersonFirstNameErrors.cs similarity index 88% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonFirstNameErrors.cs rename to src/Common/Eternelle.Common.Domain/People/PersonFirstNameErrors.cs index 0aa5e1a..62e82af 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonFirstNameErrors.cs +++ b/src/Common/Eternelle.Common.Domain/People/PersonFirstNameErrors.cs @@ -1,6 +1,6 @@ using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.Shared; +namespace Eternelle.Common.Domain.People; public static class PersonFirstNameErrors { diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonLastName.cs b/src/Common/Eternelle.Common.Domain/People/PersonLastName.cs similarity index 91% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonLastName.cs rename to src/Common/Eternelle.Common.Domain/People/PersonLastName.cs index 2f15883..e2ee8be 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonLastName.cs +++ b/src/Common/Eternelle.Common.Domain/People/PersonLastName.cs @@ -1,6 +1,6 @@ -using Eternelle.Common.Domain; +using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.Shared; +namespace Eternelle.Common.Domain.People; /// /// Last name of a partner. Paired with for partner records. diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonLastNameErrors.cs b/src/Common/Eternelle.Common.Domain/People/PersonLastNameErrors.cs similarity index 88% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonLastNameErrors.cs rename to src/Common/Eternelle.Common.Domain/People/PersonLastNameErrors.cs index 94c1757..bcda934 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonLastNameErrors.cs +++ b/src/Common/Eternelle.Common.Domain/People/PersonLastNameErrors.cs @@ -1,6 +1,6 @@ using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.Shared; +namespace Eternelle.Common.Domain.People; public static class PersonLastNameErrors { diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonName.cs b/src/Common/Eternelle.Common.Domain/People/PersonName.cs similarity index 84% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonName.cs rename to src/Common/Eternelle.Common.Domain/People/PersonName.cs index 2f69c2f..3ea4f35 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonName.cs +++ b/src/Common/Eternelle.Common.Domain/People/PersonName.cs @@ -1,10 +1,10 @@ -using Eternelle.Common.Domain; +using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.Shared; +namespace Eternelle.Common.Domain.People; /// /// Value object for the display name of a person referenced by a wedding profile -/// (entourage member, guest uploader). Single-field name — for partners that +/// (entourage member, guest uploader). Single-field name — for partners that /// split first/last, see and . /// public sealed record PersonName diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonNameErrors.cs b/src/Common/Eternelle.Common.Domain/People/PersonNameErrors.cs similarity index 87% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonNameErrors.cs rename to src/Common/Eternelle.Common.Domain/People/PersonNameErrors.cs index cdf0f34..9831272 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/PersonNameErrors.cs +++ b/src/Common/Eternelle.Common.Domain/People/PersonNameErrors.cs @@ -1,6 +1,6 @@ using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.Shared; +namespace Eternelle.Common.Domain.People; public static class PersonNameErrors { diff --git a/src/Common/Eternelle.Common.Domain/People/PhoneNumber.cs b/src/Common/Eternelle.Common.Domain/People/PhoneNumber.cs new file mode 100644 index 0000000..9440891 --- /dev/null +++ b/src/Common/Eternelle.Common.Domain/People/PhoneNumber.cs @@ -0,0 +1,29 @@ +namespace Eternelle.Common.Domain.People; + +public sealed record PhoneNumber +{ + public const int MaxLength = 30; + + private PhoneNumber(string value) { Value = value; } + public string Value { get; } + + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(PhoneNumberErrors.Empty); + } + + string trimmed = raw.Trim(); + if (trimmed.Length > MaxLength) + { + return Result.Failure(PhoneNumberErrors.TooLong); + } + + return Result.Success(new PhoneNumber(trimmed)); + } + + public override string ToString() => Value; + + internal static PhoneNumber FromPersistence(string value) => new(value); +} diff --git a/src/Common/Eternelle.Common.Domain/People/PhoneNumberErrors.cs b/src/Common/Eternelle.Common.Domain/People/PhoneNumberErrors.cs new file mode 100644 index 0000000..9feb8de --- /dev/null +++ b/src/Common/Eternelle.Common.Domain/People/PhoneNumberErrors.cs @@ -0,0 +1,11 @@ +namespace Eternelle.Common.Domain.People; + +public static class PhoneNumberErrors +{ + public static readonly Error Empty = + Error.Problem("PhoneNumber.Empty", "Phone number is required."); + + public static readonly Error TooLong = + Error.Problem("PhoneNumber.TooLong", + $"Phone number must not exceed {PhoneNumber.MaxLength} characters."); +} diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/GroupLabel.cs b/src/Common/Eternelle.Common.Domain/Text/GroupLabel.cs similarity index 82% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/GroupLabel.cs rename to src/Common/Eternelle.Common.Domain/Text/GroupLabel.cs index 8420231..97993bd 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/GroupLabel.cs +++ b/src/Common/Eternelle.Common.Domain/Text/GroupLabel.cs @@ -1,10 +1,10 @@ -using Eternelle.Common.Domain; +using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.EntourageGroups; +namespace Eternelle.Common.Domain.Text; /// /// Display label for an entourage group (e.g. "Ninongs & Ninangs", -/// "Secondary Sponsors"). Free text — couples often use Filipino-specific labels. +/// "Secondary Sponsors"). Free text — couples often use Filipino-specific labels. /// public sealed record GroupLabel { diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/GroupLabelErrors.cs b/src/Common/Eternelle.Common.Domain/Text/GroupLabelErrors.cs similarity index 85% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/GroupLabelErrors.cs rename to src/Common/Eternelle.Common.Domain/Text/GroupLabelErrors.cs index e5969c2..15c9aec 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/GroupLabelErrors.cs +++ b/src/Common/Eternelle.Common.Domain/Text/GroupLabelErrors.cs @@ -1,6 +1,6 @@ using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.EntourageGroups; +namespace Eternelle.Common.Domain.Text; public static class GroupLabelErrors { diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/InternalNote.cs b/src/Common/Eternelle.Common.Domain/Text/InternalNote.cs similarity index 91% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/InternalNote.cs rename to src/Common/Eternelle.Common.Domain/Text/InternalNote.cs index b1dd02f..0cd5837 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/InternalNote.cs +++ b/src/Common/Eternelle.Common.Domain/Text/InternalNote.cs @@ -1,6 +1,6 @@ -using Eternelle.Common.Domain; +using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.EntourageGroups; +namespace Eternelle.Common.Domain.Text; /// /// Internal-only note visible to the couple (not rendered to guests). diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/InternalNoteErrors.cs b/src/Common/Eternelle.Common.Domain/Text/InternalNoteErrors.cs similarity index 85% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/InternalNoteErrors.cs rename to src/Common/Eternelle.Common.Domain/Text/InternalNoteErrors.cs index df85d79..c7fc703 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/InternalNoteErrors.cs +++ b/src/Common/Eternelle.Common.Domain/Text/InternalNoteErrors.cs @@ -1,6 +1,6 @@ using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.EntourageGroups; +namespace Eternelle.Common.Domain.Text; public static class InternalNoteErrors { diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/RichDescription.cs b/src/Common/Eternelle.Common.Domain/Text/RichDescription.cs similarity index 90% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/RichDescription.cs rename to src/Common/Eternelle.Common.Domain/Text/RichDescription.cs index f461fb7..5837864 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/RichDescription.cs +++ b/src/Common/Eternelle.Common.Domain/Text/RichDescription.cs @@ -1,6 +1,6 @@ -using Eternelle.Common.Domain; +using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.Shared; +namespace Eternelle.Common.Domain.Text; public sealed record RichDescription { diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/RichDescriptionErrors.cs b/src/Common/Eternelle.Common.Domain/Text/RichDescriptionErrors.cs similarity index 88% rename from src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/RichDescriptionErrors.cs rename to src/Common/Eternelle.Common.Domain/Text/RichDescriptionErrors.cs index f04f92c..21a80a4 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Shared/RichDescriptionErrors.cs +++ b/src/Common/Eternelle.Common.Domain/Text/RichDescriptionErrors.cs @@ -1,6 +1,6 @@ using Eternelle.Common.Domain; -namespace Eternelle.Modules.Weddings.Domain.Shared; +namespace Eternelle.Common.Domain.Text; internal static class RichDescriptionErrors { diff --git a/src/Common/Eternelle.Common.Infrastructure/Data/DbConnectionFactory.cs b/src/Common/Eternelle.Common.Infrastructure/Data/DbConnectionFactory.cs index 9b908ff..de4ed43 100644 --- a/src/Common/Eternelle.Common.Infrastructure/Data/DbConnectionFactory.cs +++ b/src/Common/Eternelle.Common.Infrastructure/Data/DbConnectionFactory.cs @@ -6,8 +6,8 @@ namespace Eternelle.Common.Infrastructure.Data; internal sealed class DbConnectionFactory(NpgsqlDataSource dataSource) : IDbConnectionFactory { - public async ValueTask OpenConnectionAsync() + public async ValueTask OpenConnectionAsync(CancellationToken cancellationToken = default) { - return await dataSource.OpenConnectionAsync(); + return await dataSource.OpenConnectionAsync(cancellationToken); } } diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetSectionVariants/GetSectionVariantsQueryHandler.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetSectionVariants/GetSectionVariantsQueryHandler.cs index 8823201..10add5c 100644 --- a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetSectionVariants/GetSectionVariantsQueryHandler.cs +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetSectionVariants/GetSectionVariantsQueryHandler.cs @@ -14,7 +14,7 @@ public async Task>> Handle( GetSectionVariantsQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetSupportedSections/GetSupportedSectionsQueryHandler.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetSupportedSections/GetSupportedSectionsQueryHandler.cs index cd09b42..6d478df 100644 --- a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetSupportedSections/GetSupportedSectionsQueryHandler.cs +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetSupportedSections/GetSupportedSectionsQueryHandler.cs @@ -14,7 +14,7 @@ public async Task>> Handle( GetSupportedSectionsQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetTemplateById/GetTemplateByIdQueryHandler.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetTemplateById/GetTemplateByIdQueryHandler.cs index f8ea139..9cfe586 100644 --- a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetTemplateById/GetTemplateByIdQueryHandler.cs +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetTemplateById/GetTemplateByIdQueryHandler.cs @@ -26,7 +26,7 @@ public async Task> Handle( return Result.Success(cached); } - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string headSql = """ diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetTemplates/GetTemplatesQueryHandler.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetTemplates/GetTemplatesQueryHandler.cs index ba986fd..9a7dc49 100644 --- a/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetTemplates/GetTemplatesQueryHandler.cs +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Application/Templates/GetTemplates/GetTemplatesQueryHandler.cs @@ -25,7 +25,7 @@ public async Task>> Handle( return Result.Success(cached); } - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260527094230_InitialCatalog.Designer.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260529115411_InitialCatalog.Designer.cs similarity index 93% rename from src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260527094230_InitialCatalog.Designer.cs rename to src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260529115411_InitialCatalog.Designer.cs index cb09aa6..0484112 100644 --- a/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260527094230_InitialCatalog.Designer.cs +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260529115411_InitialCatalog.Designer.cs @@ -12,7 +12,7 @@ namespace Eternelle.Modules.Catalog.Infrastructure.Database.Migrations { [DbContext(typeof(CatalogDbContext))] - [Migration("20260527094230_InitialCatalog")] + [Migration("20260529115411_InitialCatalog")] partial class InitialCatalog { /// @@ -110,9 +110,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(2048)") .HasColumnName("preview_image_url"); - b.Property("Tier") - .IsRequired() - .HasColumnType("text") + b.Property("Tier") + .HasColumnType("integer") .HasColumnName("tier"); b.Property("UpdatedAtUtc") @@ -139,16 +138,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasColumnName("is_default"); - b.Property("PriceClass") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") + b.Property("PriceClass") + .HasColumnType("integer") .HasColumnName("price_class"); - b.Property("SectionId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") + b.Property("SectionId") + .HasColumnType("integer") .HasColumnName("section_id"); b.Property("TemplateId") @@ -190,10 +185,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("display_order"); - b.Property("SectionId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") + b.Property("SectionId") + .HasColumnType("integer") .HasColumnName("section_id"); b.Property("TemplateId") diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260527094230_InitialCatalog.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260529115411_InitialCatalog.cs similarity index 64% rename from src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260527094230_InitialCatalog.cs rename to src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260529115411_InitialCatalog.cs index 5cadd1b..242938b 100644 --- a/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260527094230_InitialCatalog.cs +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/20260529115411_InitialCatalog.cs @@ -1,13 +1,14 @@ -using System; +// +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Eternelle.Modules.Catalog.Infrastructure.Database.Migrations; - -/// -public partial class InitialCatalog : Migration +namespace Eternelle.Modules.Catalog.Infrastructure.Database.Migrations { + /// + public partial class InitialCatalog : Migration + { /// protected override void Up(MigrationBuilder migrationBuilder) { @@ -140,14 +141,14 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "ix_template_section_variants_template_id_section_id_variant", schema: "catalog", table: "template_section_variants", - columns: ["template_id", "section_id", "variant"], + columns: new[] { "template_id", "section_id", "variant" }, unique: true); migrationBuilder.CreateIndex( name: "ux_template_section_variants_one_default", schema: "catalog", table: "template_section_variants", - columns: ["template_id", "section_id"], + columns: new[] { "template_id", "section_id" }, unique: true, filter: "is_default = true"); @@ -155,14 +156,14 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "ix_template_supported_sections_template_id_section_id", schema: "catalog", table: "template_supported_sections", - columns: ["template_id", "section_id"], + columns: new[] { "template_id", "section_id" }, unique: true); migrationBuilder.CreateIndex( name: "ix_template_themes_template_id_theme_index", schema: "catalog", table: "template_themes", - columns: ["template_id", "theme_index"], + columns: new[] { "template_id", "theme_index" }, unique: true); migrationBuilder.CreateIndex( @@ -171,77 +172,34 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "templates", column: "name", unique: true); - - migrationBuilder.Sql( - """ - INSERT INTO catalog.templates (id, name, description, tier, preview_image_url, features, created_at_utc, updated_at_utc) - VALUES ( - '0190a000-0000-7000-8000-000000000001', - 'Kapilya', - 'A classic Filipino chapel-inspired wedding template.', - 1, - NULL, - '[]'::jsonb, - now(), - now()); - - INSERT INTO catalog.template_themes (id, template_id, theme_index, name, colors, typography, textures, is_dark) - VALUES - ('0190a000-0000-7000-8000-000000000010', '0190a000-0000-7000-8000-000000000001', 0, 'Ivory', - '{"primary":"#1a1a1a","accent":"#c8a96a","background":"#faf7f2"}'::jsonb, - '{"heading":"Cormorant","body":"Inter"}'::jsonb, NULL, false), - ('0190a000-0000-7000-8000-000000000011', '0190a000-0000-7000-8000-000000000001', 1, 'Midnight', - '{"primary":"#f5f5f5","accent":"#c8a96a","background":"#10100f"}'::jsonb, - '{"heading":"Cormorant","body":"Inter"}'::jsonb, NULL, true); - - INSERT INTO catalog.template_supported_sections (id, template_id, section_id, display_order) - VALUES - ('0190a000-0000-7000-8000-000000000020', '0190a000-0000-7000-8000-000000000001', 0, 0), - ('0190a000-0000-7000-8000-000000000021', '0190a000-0000-7000-8000-000000000001', 1, 1), - ('0190a000-0000-7000-8000-000000000022', '0190a000-0000-7000-8000-000000000001', 2, 2), - ('0190a000-0000-7000-8000-000000000023', '0190a000-0000-7000-8000-000000000001', 7, 3), - ('0190a000-0000-7000-8000-000000000024', '0190a000-0000-7000-8000-000000000001', 9, 4), - ('0190a000-0000-7000-8000-000000000025', '0190a000-0000-7000-8000-000000000001', 12, 5); - - INSERT INTO catalog.template_section_variants (id, template_id, section_id, variant, is_default, price_class, upload_limit) - VALUES - ('0190a000-0000-7000-8000-000000000030', '0190a000-0000-7000-8000-000000000001', 0, 'classic', true, 1, NULL), - ('0190a000-0000-7000-8000-000000000031', '0190a000-0000-7000-8000-000000000001', 0, 'editorial', false, 3, NULL), - ('0190a000-0000-7000-8000-000000000032', '0190a000-0000-7000-8000-000000000001', 1, 'classic', true, 1, NULL), - ('0190a000-0000-7000-8000-000000000033', '0190a000-0000-7000-8000-000000000001', 2, 'grid', true, 1, NULL), - ('0190a000-0000-7000-8000-000000000034', '0190a000-0000-7000-8000-000000000001', 7, 'map', true, 1, NULL), - ('0190a000-0000-7000-8000-000000000035', '0190a000-0000-7000-8000-000000000001', 9, 'simple', true, 1, NULL), - ('0190a000-0000-7000-8000-000000000036', '0190a000-0000-7000-8000-000000000001', 12, 'free', true, 1, 50), - ('0190a000-0000-7000-8000-000000000037', '0190a000-0000-7000-8000-000000000001', 12, 'pro', false, 2, 250), - ('0190a000-0000-7000-8000-000000000038', '0190a000-0000-7000-8000-000000000001', 12, 'plus', false, 3, NULL); - """); } - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "outbox_message_consumers", - schema: "catalog"); + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "outbox_message_consumers", + schema: "catalog"); - migrationBuilder.DropTable( - name: "outbox_messages", - schema: "catalog"); + migrationBuilder.DropTable( + name: "outbox_messages", + schema: "catalog"); - migrationBuilder.DropTable( - name: "template_section_variants", - schema: "catalog"); + migrationBuilder.DropTable( + name: "template_section_variants", + schema: "catalog"); - migrationBuilder.DropTable( - name: "template_supported_sections", - schema: "catalog"); + migrationBuilder.DropTable( + name: "template_supported_sections", + schema: "catalog"); - migrationBuilder.DropTable( - name: "template_themes", - schema: "catalog"); + migrationBuilder.DropTable( + name: "template_themes", + schema: "catalog"); - migrationBuilder.DropTable( - name: "templates", - schema: "catalog"); + migrationBuilder.DropTable( + name: "templates", + schema: "catalog"); + } } } diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/CatalogDbContextModelSnapshot.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/CatalogDbContextModelSnapshot.cs index 50e4645..a729c2c 100644 --- a/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/CatalogDbContextModelSnapshot.cs +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Database/Migrations/CatalogDbContextModelSnapshot.cs @@ -107,9 +107,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(2048)") .HasColumnName("preview_image_url"); - b.Property("Tier") - .IsRequired() - .HasColumnType("text") + b.Property("Tier") + .HasColumnType("integer") .HasColumnName("tier"); b.Property("UpdatedAtUtc") @@ -136,16 +135,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasColumnName("is_default"); - b.Property("PriceClass") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") + b.Property("PriceClass") + .HasColumnType("integer") .HasColumnName("price_class"); - b.Property("SectionId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") + b.Property("SectionId") + .HasColumnType("integer") .HasColumnName("section_id"); b.Property("TemplateId") @@ -187,10 +182,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("display_order"); - b.Property("SectionId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") + b.Property("SectionId") + .HasColumnType("integer") .HasColumnName("section_id"); b.Property("TemplateId") diff --git a/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Outbox/IdempotentDomainEventHandler.cs b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Outbox/IdempotentDomainEventHandler.cs index 84fc61e..dfb9684 100644 --- a/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Outbox/IdempotentDomainEventHandler.cs +++ b/src/Modules/Catalog/Eternelle.Modules.Catalog.Infrastructure/Outbox/IdempotentDomainEventHandler.cs @@ -16,7 +16,7 @@ internal sealed class IdempotentDomainEventHandler( { public override async Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken = default) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); var outboxMessageConsumer = new OutboxMessageConsumer(domainEvent.Id, decorated.GetType().Name); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Abstractions/Data/IUnitOfWork.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Abstractions/Data/IUnitOfWork.cs new file mode 100644 index 0000000..be78d71 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Abstractions/Data/IUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace Eternelle.Modules.Rsvp.Application.Abstractions.Data; + +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Abstractions/Security/ITokenGenerator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Abstractions/Security/ITokenGenerator.cs new file mode 100644 index 0000000..c74ed7a --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Abstractions/Security/ITokenGenerator.cs @@ -0,0 +1,6 @@ +namespace Eternelle.Modules.Rsvp.Application.Abstractions.Security; + +public interface ITokenGenerator +{ + string GenerateLookupCode(); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/AssemblyReference.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/AssemblyReference.cs new file mode 100644 index 0000000..d6a6968 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/AssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Eternelle.Modules.Rsvp.Application; + +public static class AssemblyReference +{ + public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Eternelle.Modules.Rsvp.Application.csproj b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Eternelle.Modules.Rsvp.Application.csproj new file mode 100644 index 0000000..63925c1 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Eternelle.Modules.Rsvp.Application.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/AddGuest/AddGuestCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/AddGuest/AddGuestCommand.cs new file mode 100644 index 0000000..108a917 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/AddGuest/AddGuestCommand.cs @@ -0,0 +1,13 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Guests.AddGuest; + +public sealed record AddGuestCommand( + Guid WeddingId, + string FirstName, + string LastName, + string? Email, + string? MobileNumber, + int AllowedCompanions, + int? TableNumber, + string? GroupName) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/AddGuest/AddGuestCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/AddGuest/AddGuestCommandHandler.cs new file mode 100644 index 0000000..e003a81 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/AddGuest/AddGuestCommandHandler.cs @@ -0,0 +1,99 @@ +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Application.Abstractions.Security; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Shared; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Guests.AddGuest; + +internal sealed class AddGuestCommandHandler( + IGuestRepository guestRepository, + IWeddingProjection weddings, + ITokenGenerator tokenGenerator, + IUnitOfWork unitOfWork, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task> Handle(AddGuestCommand command, CancellationToken cancellationToken) + { + var weddingId = new WeddingId(command.WeddingId); + Wedding? wedding = await weddings.GetAsync(weddingId, cancellationToken); + if (wedding is null) + { + return Result.Failure(WeddingErrors.NotFound(weddingId)); + } + + Result firstResult = PersonFirstName.Create(command.FirstName); + if (firstResult.IsFailure) + { + return Result.Failure(firstResult.Error); + } + + Result lastResult = PersonLastName.Create(command.LastName); + if (lastResult.IsFailure) + { + return Result.Failure(lastResult.Error); + } + + EmailAddress? email = null; + if (command.Email is not null) + { + Result emailResult = EmailAddress.Create(command.Email); + if (emailResult.IsFailure) + { + return Result.Failure(emailResult.Error); + } + email = emailResult.Value; + } + + PhoneNumber? phone = null; + if (command.MobileNumber is not null) + { + Result phoneResult = PhoneNumber.Create(command.MobileNumber); + if (phoneResult.IsFailure) + { + return Result.Failure(phoneResult.Error); + } + phone = phoneResult.Value; + } + + GroupLabel? groupLabel = null; + if (command.GroupName is not null) + { + Result groupResult = GroupLabel.Create(command.GroupName); + if (groupResult.IsFailure) + { + return Result.Failure(groupResult.Error); + } + groupLabel = groupResult.Value; + } + + LookupCode lookupCode; + while (true) + { + string raw = tokenGenerator.GenerateLookupCode(); + if (!await guestRepository.LookupCodeExistsAsync(raw, cancellationToken)) + { + lookupCode = LookupCode.Create(raw).Value; + break; + } + } + + Result guestResult = Guest.Create( + weddingId, firstResult.Value, lastResult.Value, email, phone, lookupCode, + command.AllowedCompanions, command.TableNumber, groupLabel, clock.UtcNow); + if (guestResult.IsFailure) + { + return Result.Failure(guestResult.Error); + } + + guestRepository.Insert(guestResult.Value); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return guestResult.Value.Id.Value; + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/AddGuest/AddGuestCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/AddGuest/AddGuestCommandValidator.cs new file mode 100644 index 0000000..1ef81c7 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/AddGuest/AddGuestCommandValidator.cs @@ -0,0 +1,19 @@ +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Guests.AddGuest; + +internal sealed class AddGuestCommandValidator : AbstractValidator +{ + public AddGuestCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + RuleFor(c => c.FirstName).NotEmpty().MaximumLength(PersonFirstName.MaxLength); + RuleFor(c => c.LastName).NotEmpty().MaximumLength(PersonLastName.MaxLength); + RuleFor(c => c.Email).MaximumLength(EmailAddress.MaxLength).When(c => c.Email is not null); + RuleFor(c => c.MobileNumber).MaximumLength(PhoneNumber.MaxLength).When(c => c.MobileNumber is not null); + RuleFor(c => c.AllowedCompanions).GreaterThanOrEqualTo(0); + RuleFor(c => c.GroupName).MaximumLength(GroupLabel.MaxLength).When(c => c.GroupName is not null); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ChangeGuestAllowance/ChangeGuestAllowanceCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ChangeGuestAllowance/ChangeGuestAllowanceCommand.cs new file mode 100644 index 0000000..9da80c4 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ChangeGuestAllowance/ChangeGuestAllowanceCommand.cs @@ -0,0 +1,8 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Guests.ChangeGuestAllowance; + +public sealed record ChangeGuestAllowanceCommand( + Guid WeddingId, + Guid GuestId, + int AllowedCompanions) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ChangeGuestAllowance/ChangeGuestAllowanceCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ChangeGuestAllowance/ChangeGuestAllowanceCommandHandler.cs new file mode 100644 index 0000000..29753a1 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ChangeGuestAllowance/ChangeGuestAllowanceCommandHandler.cs @@ -0,0 +1,35 @@ +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Guests.ChangeGuestAllowance; + +internal sealed class ChangeGuestAllowanceCommandHandler( + IGuestRepository guestRepository, + IUnitOfWork unitOfWork, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task Handle(ChangeGuestAllowanceCommand command, CancellationToken cancellationToken) + { + var guestId = new GuestId(command.GuestId); + Guest? guest = await guestRepository.GetByIdAsync(guestId, cancellationToken); + if (guest is null || guest.WeddingId != new WeddingId(command.WeddingId)) + { + return Result.Failure(GuestErrors.NotFound(guestId)); + } + + Result result = guest.ChangeAllowedCompanions(command.AllowedCompanions, clock.UtcNow); + if (result.IsFailure) + { + return result; + } + + guestRepository.Update(guest); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ChangeGuestAllowance/ChangeGuestAllowanceCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ChangeGuestAllowance/ChangeGuestAllowanceCommandValidator.cs new file mode 100644 index 0000000..fc70bc0 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ChangeGuestAllowance/ChangeGuestAllowanceCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Guests.ChangeGuestAllowance; + +internal sealed class ChangeGuestAllowanceCommandValidator : AbstractValidator +{ + public ChangeGuestAllowanceCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + RuleFor(c => c.GuestId).NotEmpty(); + RuleFor(c => c.AllowedCompanions).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GetGuest/GetGuestQuery.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GetGuest/GetGuestQuery.cs new file mode 100644 index 0000000..607428e --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GetGuest/GetGuestQuery.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Guests.GetGuest; + +public sealed record GetGuestQuery(Guid WeddingId, Guid GuestId) : IQuery; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GetGuest/GetGuestQueryHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GetGuest/GetGuestQueryHandler.cs new file mode 100644 index 0000000..a8ce2da --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GetGuest/GetGuestQueryHandler.cs @@ -0,0 +1,96 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Guests; + +namespace Eternelle.Modules.Rsvp.Application.Guests.GetGuest; + +internal sealed class GetGuestQueryHandler(IDbConnectionFactory dbConnectionFactory) + : IQueryHandler +{ + public async Task> Handle(GetGuestQuery query, CancellationToken cancellationToken) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); + + const string guestSql = + $""" + SELECT + id AS {nameof(GuestRow.Id)}, + wedding_id AS {nameof(GuestRow.WeddingId)}, + first_name AS {nameof(GuestRow.FirstName)}, + last_name AS {nameof(GuestRow.LastName)}, + email AS {nameof(GuestRow.Email)}, + mobile_number AS {nameof(GuestRow.MobileNumber)}, + lookup_code AS {nameof(GuestRow.LookupCode)}, + allowed_companions AS {nameof(GuestRow.AllowedCompanions)}, + table_number AS {nameof(GuestRow.TableNumber)}, + group_name AS {nameof(GuestRow.GroupName)}, + is_locked AS {nameof(GuestRow.IsLocked)}, + locked_at_utc AS {nameof(GuestRow.LockedAtUtc)}, + created_at AS {nameof(GuestRow.CreatedAtUtc)}, + updated_at AS {nameof(GuestRow.UpdatedAtUtc)} + FROM rsvp.guests + WHERE id = @GuestId AND wedding_id = @WeddingId + """; + + GuestRow? guest = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(guestSql, new { query.GuestId, query.WeddingId }, cancellationToken: cancellationToken)); + + if (guest is null) + { + return Result.Failure(GuestErrors.NotFound(new GuestId(query.GuestId))); + } + + const string submissionSql = + $""" + SELECT + s.id AS {nameof(SubmissionSummaryResponse.SubmissionId)}, + s.attending AS {nameof(SubmissionSummaryResponse.Attending)}, + (SELECT COUNT(*) FROM rsvp.submission_companions sc WHERE sc.submission_id = s.id) + AS {nameof(SubmissionSummaryResponse.CompanionCount)}, + s.submitted_at AS {nameof(SubmissionSummaryResponse.SubmittedAtUtc)} + FROM rsvp.submissions s + WHERE s.guest_id = @GuestId + ORDER BY s.submitted_at DESC + LIMIT 1 + """; + + SubmissionSummaryResponse? latest = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(submissionSql, new { query.GuestId }, cancellationToken: cancellationToken)); + + return Result.Success(new GuestResponse( + guest.Id, + guest.WeddingId, + guest.FirstName, + guest.LastName, + guest.Email, + guest.MobileNumber, + guest.LookupCode, + guest.AllowedCompanions, + guest.TableNumber, + guest.GroupName, + guest.IsLocked, + guest.LockedAtUtc, + guest.CreatedAtUtc, + guest.UpdatedAtUtc, + latest)); + } + + private sealed record GuestRow( + Guid Id, + Guid WeddingId, + string FirstName, + string LastName, + string? Email, + string? MobileNumber, + string LookupCode, + int AllowedCompanions, + int? TableNumber, + string? GroupName, + bool IsLocked, + DateTime? LockedAtUtc, + DateTime CreatedAtUtc, + DateTime UpdatedAtUtc); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GetGuest/GuestResponse.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GetGuest/GuestResponse.cs new file mode 100644 index 0000000..baf81a2 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GetGuest/GuestResponse.cs @@ -0,0 +1,24 @@ +namespace Eternelle.Modules.Rsvp.Application.Guests.GetGuest; + +public sealed record GuestResponse( + Guid Id, + Guid WeddingId, + string FirstName, + string LastName, + string? Email, + string? MobileNumber, + string LookupCode, + int AllowedCompanions, + int? TableNumber, + string? GroupName, + bool IsLocked, + DateTime? LockedAtUtc, + DateTime CreatedAtUtc, + DateTime UpdatedAtUtc, + SubmissionSummaryResponse? LatestSubmission); + +public sealed record SubmissionSummaryResponse( + Guid SubmissionId, + bool Attending, + int CompanionCount, + DateTime SubmittedAtUtc); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GuestAddedDomainEventHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GuestAddedDomainEventHandler.cs new file mode 100644 index 0000000..a7dff6e --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/GuestAddedDomainEventHandler.cs @@ -0,0 +1,21 @@ +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Application.Messaging; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.IntegrationEvents.Guests; + +namespace Eternelle.Modules.Rsvp.Application.Guests; + +internal sealed class GuestAddedDomainEventHandler(IEventBus eventBus) + : DomainEventHandler +{ + public override Task Handle(GuestAddedDomainEvent domainEvent, CancellationToken cancellationToken = default) => + eventBus.PublishAsync( + new GuestAddedIntegrationEvent( + domainEvent.Id, + domainEvent.OccurredOnUtc, + domainEvent.GuestId.Value, + domainEvent.WeddingId.Value, + domainEvent.LookupCode, + domainEvent.AllowedCompanions), + cancellationToken); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/GuestSummaryResponse.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/GuestSummaryResponse.cs new file mode 100644 index 0000000..440d4da --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/GuestSummaryResponse.cs @@ -0,0 +1,13 @@ +namespace Eternelle.Modules.Rsvp.Application.Guests.ListGuests; + +public sealed record GuestSummaryResponse( + Guid Id, + string FirstName, + string LastName, + int AllowedCompanions, + string? GroupName, + int? TableNumber, + bool IsLocked, + bool? LatestAttending, + int LatestCompanionCount, + string LookupCode); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/ListGuestsQuery.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/ListGuestsQuery.cs new file mode 100644 index 0000000..bb55dcb --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/ListGuestsQuery.cs @@ -0,0 +1,11 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Guests.ListGuests; + +public sealed record ListGuestsQuery( + Guid WeddingId, + string? GroupName, + string? AttendingState, + bool? IsLocked, + int Skip = 0, + int Take = 50) : IQuery>; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/ListGuestsQueryHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/ListGuestsQueryHandler.cs new file mode 100644 index 0000000..eaf1c22 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/ListGuestsQueryHandler.cs @@ -0,0 +1,64 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Rsvp.Application.Guests.ListGuests; + +internal sealed class ListGuestsQueryHandler(IDbConnectionFactory dbConnectionFactory) + : IQueryHandler> +{ + public async Task>> Handle(ListGuestsQuery query, CancellationToken cancellationToken) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); + + const string sql = + $""" + WITH latest_sub AS ( + SELECT DISTINCT ON (guest_id) + id, + guest_id, + attending + FROM rsvp.submissions + WHERE guest_id IS NOT NULL + ORDER BY guest_id, submitted_at DESC + ), + companion_count AS ( + SELECT submission_id, COUNT(*)::int AS cnt + FROM rsvp.submission_companions + GROUP BY submission_id + ) + SELECT + g.id AS {nameof(GuestSummaryResponse.Id)}, + g.first_name AS {nameof(GuestSummaryResponse.FirstName)}, + g.last_name AS {nameof(GuestSummaryResponse.LastName)}, + g.allowed_companions AS {nameof(GuestSummaryResponse.AllowedCompanions)}, + g.group_name AS {nameof(GuestSummaryResponse.GroupName)}, + g.table_number AS {nameof(GuestSummaryResponse.TableNumber)}, + g.is_locked AS {nameof(GuestSummaryResponse.IsLocked)}, + ls.attending AS {nameof(GuestSummaryResponse.LatestAttending)}, + COALESCE(cc.cnt, 0) AS {nameof(GuestSummaryResponse.LatestCompanionCount)}, + g.lookup_code AS {nameof(GuestSummaryResponse.LookupCode)} + FROM rsvp.guests g + LEFT JOIN latest_sub ls ON ls.guest_id = g.id + LEFT JOIN companion_count cc ON cc.submission_id = ls.id + WHERE g.wedding_id = @WeddingId + AND (@GroupName IS NULL OR g.group_name = @GroupName) + AND (@IsLocked IS NULL OR g.is_locked = @IsLocked) + AND ( + @AttendingState IS NULL + OR (@AttendingState = 'attending' AND ls.attending = true) + OR (@AttendingState = 'declined' AND ls.attending = false) + OR (@AttendingState = 'no_response' AND ls.id IS NULL) + ) + ORDER BY g.last_name, g.first_name + LIMIT @Take OFFSET @Skip + """; + + IEnumerable rows = await connection.QueryAsync( + new CommandDefinition(sql, query, cancellationToken: cancellationToken)); + + return Result.Success>([.. rows]); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/ListGuestsQueryValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/ListGuestsQueryValidator.cs new file mode 100644 index 0000000..f00d4b4 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/ListGuests/ListGuestsQueryValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Guests.ListGuests; + +internal sealed class ListGuestsQueryValidator : AbstractValidator +{ + public ListGuestsQueryValidator() + { + RuleFor(q => q.WeddingId).NotEmpty(); + RuleFor(q => q.Take).InclusiveBetween(1, 100).WithMessage("Take must be between 1 and 100."); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/LockGuest/LockGuestCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/LockGuest/LockGuestCommand.cs new file mode 100644 index 0000000..8be50a3 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/LockGuest/LockGuestCommand.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Guests.LockGuest; + +public sealed record LockGuestCommand(Guid WeddingId, Guid GuestId) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/LockGuest/LockGuestCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/LockGuest/LockGuestCommandHandler.cs new file mode 100644 index 0000000..8d876a2 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/LockGuest/LockGuestCommandHandler.cs @@ -0,0 +1,35 @@ +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Guests.LockGuest; + +internal sealed class LockGuestCommandHandler( + IGuestRepository guestRepository, + IUnitOfWork unitOfWork, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task Handle(LockGuestCommand command, CancellationToken cancellationToken) + { + var guestId = new GuestId(command.GuestId); + Guest? guest = await guestRepository.GetByIdAsync(guestId, cancellationToken); + if (guest is null || guest.WeddingId != new WeddingId(command.WeddingId)) + { + return Result.Failure(GuestErrors.NotFound(guestId)); + } + + Result lockResult = guest.Lock(clock.UtcNow); + if (lockResult.IsFailure) + { + return lockResult; + } + + guestRepository.Update(guest); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/LockGuest/LockGuestCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/LockGuest/LockGuestCommandValidator.cs new file mode 100644 index 0000000..66c9db3 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/LockGuest/LockGuestCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Guests.LockGuest; + +internal sealed class LockGuestCommandValidator : AbstractValidator +{ + public LockGuestCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + RuleFor(c => c.GuestId).NotEmpty(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RegenerateGuestToken/RegenerateGuestTokenCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RegenerateGuestToken/RegenerateGuestTokenCommand.cs new file mode 100644 index 0000000..3c69721 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RegenerateGuestToken/RegenerateGuestTokenCommand.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Guests.RegenerateGuestToken; + +public sealed record RegenerateGuestTokenCommand(Guid WeddingId, Guid GuestId) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RegenerateGuestToken/RegenerateGuestTokenCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RegenerateGuestToken/RegenerateGuestTokenCommandHandler.cs new file mode 100644 index 0000000..0f47730 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RegenerateGuestToken/RegenerateGuestTokenCommandHandler.cs @@ -0,0 +1,51 @@ +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Application.Abstractions.Security; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Shared; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Guests.RegenerateGuestToken; + +internal sealed class RegenerateGuestTokenCommandHandler( + IGuestRepository guestRepository, + ITokenGenerator tokenGenerator, + IUnitOfWork unitOfWork, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task> Handle(RegenerateGuestTokenCommand command, CancellationToken cancellationToken) + { + var guestId = new GuestId(command.GuestId); + Guest? guest = await guestRepository.GetByIdAsync(guestId, cancellationToken); + if (guest is null || guest.WeddingId != new WeddingId(command.WeddingId)) + { + return Result.Failure(GuestErrors.NotFound(guestId)); + } + + const int maxAttempts = 5; + LookupCode? newCode = null; + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + string raw = tokenGenerator.GenerateLookupCode(); + if (!await guestRepository.LookupCodeExistsAsync(raw, cancellationToken)) + { + newCode = LookupCode.Create(raw).Value; + break; + } + } + + if (newCode is null) + { + return Result.Failure(GuestErrors.LookupCodeAlreadyTaken); + } + + guest.RegenerateLookupCode(newCode, clock.UtcNow); + + guestRepository.Update(guest); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(newCode.Value); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RegenerateGuestToken/RegenerateGuestTokenCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RegenerateGuestToken/RegenerateGuestTokenCommandValidator.cs new file mode 100644 index 0000000..8b751a1 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RegenerateGuestToken/RegenerateGuestTokenCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Guests.RegenerateGuestToken; + +internal sealed class RegenerateGuestTokenCommandValidator : AbstractValidator +{ + public RegenerateGuestTokenCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + RuleFor(c => c.GuestId).NotEmpty(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RemoveGuest/RemoveGuestCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RemoveGuest/RemoveGuestCommand.cs new file mode 100644 index 0000000..6d2dab6 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RemoveGuest/RemoveGuestCommand.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Guests.RemoveGuest; + +public sealed record RemoveGuestCommand(Guid WeddingId, Guid GuestId) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RemoveGuest/RemoveGuestCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RemoveGuest/RemoveGuestCommandHandler.cs new file mode 100644 index 0000000..5cb20b2 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RemoveGuest/RemoveGuestCommandHandler.cs @@ -0,0 +1,33 @@ +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Submissions; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Guests.RemoveGuest; + +internal sealed class RemoveGuestCommandHandler( + IGuestRepository guestRepository, + IRsvpSubmissionRepository submissionRepository, + IUnitOfWork unitOfWork) : ICommandHandler +{ + public async Task Handle(RemoveGuestCommand command, CancellationToken cancellationToken) + { + var guestId = new GuestId(command.GuestId); + Guest? guest = await guestRepository.GetByIdAsync(guestId, cancellationToken); + if (guest is null || guest.WeddingId != new WeddingId(command.WeddingId)) + { + return Result.Failure(GuestErrors.NotFound(guestId)); + } + + await submissionRepository.NullifyGuestIdAsync(new GuestId(command.GuestId), cancellationToken); + + guest.MarkRemoved(); + + guestRepository.Remove(guest); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RemoveGuest/RemoveGuestCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RemoveGuest/RemoveGuestCommandValidator.cs new file mode 100644 index 0000000..867ec87 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/RemoveGuest/RemoveGuestCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Guests.RemoveGuest; + +internal sealed class RemoveGuestCommandValidator : AbstractValidator +{ + public RemoveGuestCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + RuleFor(c => c.GuestId).NotEmpty(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UnlockGuest/UnlockGuestCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UnlockGuest/UnlockGuestCommand.cs new file mode 100644 index 0000000..c5a1e65 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UnlockGuest/UnlockGuestCommand.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Guests.UnlockGuest; + +public sealed record UnlockGuestCommand(Guid WeddingId, Guid GuestId) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UnlockGuest/UnlockGuestCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UnlockGuest/UnlockGuestCommandHandler.cs new file mode 100644 index 0000000..4f3e284 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UnlockGuest/UnlockGuestCommandHandler.cs @@ -0,0 +1,35 @@ +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Guests.UnlockGuest; + +internal sealed class UnlockGuestCommandHandler( + IGuestRepository guestRepository, + IUnitOfWork unitOfWork, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task Handle(UnlockGuestCommand command, CancellationToken cancellationToken) + { + var guestId = new GuestId(command.GuestId); + Guest? guest = await guestRepository.GetByIdAsync(guestId, cancellationToken); + if (guest is null || guest.WeddingId != new WeddingId(command.WeddingId)) + { + return Result.Failure(GuestErrors.NotFound(guestId)); + } + + Result unlockResult = guest.Unlock(clock.UtcNow); + if (unlockResult.IsFailure) + { + return unlockResult; + } + + guestRepository.Update(guest); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UnlockGuest/UnlockGuestCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UnlockGuest/UnlockGuestCommandValidator.cs new file mode 100644 index 0000000..545fde9 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UnlockGuest/UnlockGuestCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Guests.UnlockGuest; + +internal sealed class UnlockGuestCommandValidator : AbstractValidator +{ + public UnlockGuestCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + RuleFor(c => c.GuestId).NotEmpty(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestContact/UpdateGuestContactCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestContact/UpdateGuestContactCommand.cs new file mode 100644 index 0000000..124a77f --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestContact/UpdateGuestContactCommand.cs @@ -0,0 +1,11 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Guests.UpdateGuestContact; + +public sealed record UpdateGuestContactCommand( + Guid WeddingId, + Guid GuestId, + string FirstName, + string LastName, + string? Email, + string? MobileNumber) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestContact/UpdateGuestContactCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestContact/UpdateGuestContactCommandHandler.cs new file mode 100644 index 0000000..42e21c7 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestContact/UpdateGuestContactCommandHandler.cs @@ -0,0 +1,66 @@ +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Guests.UpdateGuestContact; + +internal sealed class UpdateGuestContactCommandHandler( + IGuestRepository guestRepository, + IUnitOfWork unitOfWork, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task Handle(UpdateGuestContactCommand command, CancellationToken cancellationToken) + { + var guestId = new GuestId(command.GuestId); + Guest? guest = await guestRepository.GetByIdAsync(guestId, cancellationToken); + if (guest is null || guest.WeddingId != new WeddingId(command.WeddingId)) + { + return Result.Failure(GuestErrors.NotFound(guestId)); + } + + Result firstResult = PersonFirstName.Create(command.FirstName); + if (firstResult.IsFailure) + { + return Result.Failure(firstResult.Error); + } + + Result lastResult = PersonLastName.Create(command.LastName); + if (lastResult.IsFailure) + { + return Result.Failure(lastResult.Error); + } + + EmailAddress? email = null; + if (command.Email is not null) + { + Result emailResult = EmailAddress.Create(command.Email); + if (emailResult.IsFailure) + { + return Result.Failure(emailResult.Error); + } + email = emailResult.Value; + } + + PhoneNumber? phone = null; + if (command.MobileNumber is not null) + { + Result phoneResult = PhoneNumber.Create(command.MobileNumber); + if (phoneResult.IsFailure) + { + return Result.Failure(phoneResult.Error); + } + phone = phoneResult.Value; + } + + guest.UpdateContact(firstResult.Value, lastResult.Value, email, phone, clock.UtcNow); + + guestRepository.Update(guest); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestContact/UpdateGuestContactCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestContact/UpdateGuestContactCommandValidator.cs new file mode 100644 index 0000000..cd45f4b --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestContact/UpdateGuestContactCommandValidator.cs @@ -0,0 +1,17 @@ +using Eternelle.Common.Domain.People; +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Guests.UpdateGuestContact; + +internal sealed class UpdateGuestContactCommandValidator : AbstractValidator +{ + public UpdateGuestContactCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + RuleFor(c => c.GuestId).NotEmpty(); + RuleFor(c => c.FirstName).NotEmpty().MaximumLength(PersonFirstName.MaxLength); + RuleFor(c => c.LastName).NotEmpty().MaximumLength(PersonLastName.MaxLength); + RuleFor(c => c.Email).MaximumLength(EmailAddress.MaxLength).When(c => c.Email is not null); + RuleFor(c => c.MobileNumber).MaximumLength(PhoneNumber.MaxLength).When(c => c.MobileNumber is not null); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestPlacement/UpdateGuestPlacementCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestPlacement/UpdateGuestPlacementCommand.cs new file mode 100644 index 0000000..31319f1 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestPlacement/UpdateGuestPlacementCommand.cs @@ -0,0 +1,9 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Guests.UpdateGuestPlacement; + +public sealed record UpdateGuestPlacementCommand( + Guid WeddingId, + Guid GuestId, + int? TableNumber, + string? GroupName) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestPlacement/UpdateGuestPlacementCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestPlacement/UpdateGuestPlacementCommandHandler.cs new file mode 100644 index 0000000..3bb4209 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestPlacement/UpdateGuestPlacementCommandHandler.cs @@ -0,0 +1,43 @@ +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Common.Domain.Text; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Guests.UpdateGuestPlacement; + +internal sealed class UpdateGuestPlacementCommandHandler( + IGuestRepository guestRepository, + IUnitOfWork unitOfWork, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task Handle(UpdateGuestPlacementCommand command, CancellationToken cancellationToken) + { + var guestId = new GuestId(command.GuestId); + Guest? guest = await guestRepository.GetByIdAsync(guestId, cancellationToken); + if (guest is null || guest.WeddingId != new WeddingId(command.WeddingId)) + { + return Result.Failure(GuestErrors.NotFound(guestId)); + } + + GroupLabel? groupLabel = null; + if (command.GroupName is not null) + { + Result groupResult = GroupLabel.Create(command.GroupName); + if (groupResult.IsFailure) + { + return Result.Failure(groupResult.Error); + } + groupLabel = groupResult.Value; + } + + guest.UpdatePlacement(command.TableNumber, groupLabel, clock.UtcNow); + + guestRepository.Update(guest); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestPlacement/UpdateGuestPlacementCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestPlacement/UpdateGuestPlacementCommandValidator.cs new file mode 100644 index 0000000..7ed8733 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Guests/UpdateGuestPlacement/UpdateGuestPlacementCommandValidator.cs @@ -0,0 +1,14 @@ +using Eternelle.Common.Domain.Text; +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Guests.UpdateGuestPlacement; + +internal sealed class UpdateGuestPlacementCommandValidator : AbstractValidator +{ + public UpdateGuestPlacementCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + RuleFor(c => c.GuestId).NotEmpty(); + RuleFor(c => c.GroupName).MaximumLength(GroupLabel.MaxLength).When(c => c.GroupName is not null); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/GetInvitation/GetInvitationQuery.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/GetInvitation/GetInvitationQuery.cs new file mode 100644 index 0000000..89b78bd --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/GetInvitation/GetInvitationQuery.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Public.GetInvitation; + +public sealed record GetInvitationQuery(string LookupCode) : IQuery; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/GetInvitation/GetInvitationQueryHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/GetInvitation/GetInvitationQueryHandler.cs new file mode 100644 index 0000000..170dbba --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/GetInvitation/GetInvitationQueryHandler.cs @@ -0,0 +1,148 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Shared; + +namespace Eternelle.Modules.Rsvp.Application.Public.GetInvitation; + +internal sealed class GetInvitationQueryHandler( + IDbConnectionFactory dbConnectionFactory, + IDateTimeProvider clock) : IQueryHandler +{ + private static readonly Error NotFound = + Error.NotFound("Rsvp.Invitation.NotFound", "Not found."); + + public async Task> Handle(GetInvitationQuery query, CancellationToken cancellationToken) + { + Result codeResult = LookupCode.Create(query.LookupCode); + if (codeResult.IsFailure) + { + return Result.Failure(NotFound); + } + + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); + + const string guestSql = + $""" + SELECT + id AS {nameof(GuestRow.Id)}, + wedding_id AS {nameof(GuestRow.WeddingId)}, + first_name AS {nameof(GuestRow.FirstName)}, + last_name AS {nameof(GuestRow.LastName)}, + allowed_companions AS {nameof(GuestRow.AllowedCompanions)}, + is_locked AS {nameof(GuestRow.IsLocked)} + FROM rsvp.guests + WHERE lookup_code = @LookupCode + """; + + string normalizedCode = codeResult.Value.Value; + GuestRow? guest = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(guestSql, new { LookupCode = normalizedCode }, cancellationToken: cancellationToken)); + + if (guest is null) + { + return Result.Failure(NotFound); + } + + const string weddingSql = + $""" + SELECT + wedding_date AS {nameof(WeddingRow.WeddingDate)}, + rsvp_deadline AS {nameof(WeddingRow.RsvpDeadline)}, + is_closed AS {nameof(WeddingRow.IsClosed)} + FROM rsvp.weddings + WHERE id = @WeddingId + """; + + WeddingRow? wedding = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(weddingSql, new { guest.WeddingId }, cancellationToken: cancellationToken)); + + if (wedding is null) + { + return Result.Failure(NotFound); + } + + const string submissionSql = + $""" + SELECT + id AS {nameof(SubmissionRow.Id)}, + attending AS {nameof(SubmissionRow.Attending)}, + dietary_note AS {nameof(SubmissionRow.DietaryNote)}, + message AS {nameof(SubmissionRow.Message)}, + submitted_at AS {nameof(SubmissionRow.SubmittedAtUtc)} + FROM rsvp.submissions + WHERE guest_id = @GuestId + ORDER BY submitted_at DESC + LIMIT 1 + """; + + SubmissionRow? latest = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(submissionSql, new { GuestId = guest.Id }, cancellationToken: cancellationToken)); + + IReadOnlyList companions = []; + if (latest is not null) + { + const string companionSql = + $""" + SELECT + name AS {nameof(CompanionResponse.Name)}, + attending AS {nameof(CompanionResponse.Attending)}, + dietary_note AS {nameof(CompanionResponse.DietaryNote)} + FROM rsvp.submission_companions + WHERE submission_id = @SubmissionId + ORDER BY display_order + """; + + companions = [.. await connection.QueryAsync( + new CommandDefinition(companionSql, new { SubmissionId = latest.Id }, cancellationToken: cancellationToken))]; + } + + var today = DateOnly.FromDateTime(clock.UtcNow.Date); + bool isAccepting = !wedding.IsClosed + && (wedding.RsvpDeadline is null || today <= wedding.RsvpDeadline) + && !guest.IsLocked; + + SubmissionDetailResponse? currentSubmission = latest is null + ? null + : new SubmissionDetailResponse( + latest.Id, + latest.Attending, + latest.DietaryNote, + latest.Message, + companions, + latest.SubmittedAtUtc); + + return Result.Success(new InvitationResponse( + guest.FirstName, + guest.LastName, + guest.AllowedCompanions, + wedding.WeddingDate, + wedding.RsvpDeadline, + guest.IsLocked, + isAccepting, + currentSubmission)); + } + + private sealed record GuestRow( + Guid Id, + Guid WeddingId, + string FirstName, + string LastName, + int AllowedCompanions, + bool IsLocked); + + private sealed record WeddingRow( + DateOnly WeddingDate, + DateOnly? RsvpDeadline, + bool IsClosed); + + private sealed record SubmissionRow( + Guid Id, + bool Attending, + string? DietaryNote, + string? Message, + DateTime SubmittedAtUtc); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/GetInvitation/InvitationResponse.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/GetInvitation/InvitationResponse.cs new file mode 100644 index 0000000..8a5de5a --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/GetInvitation/InvitationResponse.cs @@ -0,0 +1,21 @@ +namespace Eternelle.Modules.Rsvp.Application.Public.GetInvitation; + +public sealed record InvitationResponse( + string FirstName, + string LastName, + int AllowedCompanions, + DateOnly WeddingDate, + DateOnly? RsvpDeadline, + bool IsLocked, + bool IsAcceptingSubmissions, + SubmissionDetailResponse? CurrentSubmission); + +public sealed record SubmissionDetailResponse( + Guid SubmissionId, + bool Attending, + string? DietaryNote, + string? Message, + IReadOnlyList Companions, + DateTime SubmittedAtUtc); + +public sealed record CompanionResponse(string Name, bool Attending, string? DietaryNote); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/SubmitRsvp/SubmitRsvpCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/SubmitRsvp/SubmitRsvpCommand.cs new file mode 100644 index 0000000..33c5cd6 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/SubmitRsvp/SubmitRsvpCommand.cs @@ -0,0 +1,13 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Public.SubmitRsvp; + +public sealed record CompanionInput(string Name, bool Attending, string? DietaryNote); + +public sealed record SubmitRsvpCommand( + string LookupCode, + bool Attending, + string? DietaryNote, + string? Message, + IReadOnlyList Companions, + string? IpAddress) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/SubmitRsvp/SubmitRsvpCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/SubmitRsvp/SubmitRsvpCommandHandler.cs new file mode 100644 index 0000000..73bbab3 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/SubmitRsvp/SubmitRsvpCommandHandler.cs @@ -0,0 +1,130 @@ +using System.Net; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Shared; +using Eternelle.Modules.Rsvp.Domain.Submissions; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Public.SubmitRsvp; + +internal sealed class SubmitRsvpCommandHandler( + IGuestRepository guestRepository, + IWeddingProjection weddingProjection, + IRsvpSubmissionRepository submissionRepository, + IUnitOfWork unitOfWork, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task> Handle(SubmitRsvpCommand command, CancellationToken cancellationToken) + { + Result codeResult = LookupCode.Create(command.LookupCode); + if (codeResult.IsFailure) + { + return Result.Failure( + Error.NotFound("Rsvp.Invitation.NotFound", "Not found.")); + } + + Guest? guest = await guestRepository.GetByLookupCodeAsync(codeResult.Value.Value, cancellationToken); + if (guest is null) + { + return Result.Failure( + Error.NotFound("Rsvp.Invitation.NotFound", "Not found.")); + } + + Wedding? wedding = await weddingProjection.GetAsync(guest.WeddingId, cancellationToken); + if (wedding is null) + { + return Result.Failure( + Error.NotFound("Rsvp.Invitation.NotFound", "Not found.")); + } + + var today = DateOnly.FromDateTime(clock.UtcNow.Date); + if (!wedding.IsAcceptingSubmissions(today) || guest.IsLocked) + { + return Result.Failure(RsvpSubmissionErrors.WeddingClosed); + } + + Result nameSnapshotResult = PersonName.Create( + $"{guest.FirstName.Value} {guest.LastName.Value}"); + if (nameSnapshotResult.IsFailure) + { + return Result.Failure(nameSnapshotResult.Error); + } + PersonName nameSnapshot = nameSnapshotResult.Value; + + DietaryNote? dietary = null; + if (command.DietaryNote is not null) + { + Result dietaryResult = DietaryNote.Create(command.DietaryNote); + if (dietaryResult.IsFailure) + { + return Result.Failure(dietaryResult.Error); + } + dietary = dietaryResult.Value; + } + + RichDescription? message = null; + if (command.Message is not null) + { + Result messageResult = RichDescription.Create(command.Message); + if (messageResult.IsFailure) + { + return Result.Failure(messageResult.Error); + } + message = messageResult.Value; + } + + var companionList = new List<(PersonName name, bool attending, DietaryNote? dietary)>(); + foreach (CompanionInput c in command.Companions) + { + Result nameResult = PersonName.Create(c.Name); + if (nameResult.IsFailure) + { + return Result.Failure(nameResult.Error); + } + + DietaryNote? companionDietary = null; + if (c.DietaryNote is not null) + { + Result companionDietaryResult = DietaryNote.Create(c.DietaryNote); + if (companionDietaryResult.IsFailure) + { + return Result.Failure(companionDietaryResult.Error); + } + companionDietary = companionDietaryResult.Value; + } + + companionList.Add((nameResult.Value, c.Attending, companionDietary)); + } + + IPAddress? ipAddress = IPAddress.TryParse(command.IpAddress, out IPAddress? parsed) ? parsed : null; + + Result result = RsvpSubmission.Create( + guest, + nameSnapshot, + guest.Email, + guest.MobileNumber, + command.Attending, + dietary, + message, + companionList, + ipAddress, + RsvpSource.GuestSelf, + internalNote: null, + clock.UtcNow); + + if (result.IsFailure) + { + return Result.Failure(result.Error); + } + + submissionRepository.Insert(result.Value); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(result.Value.ConfirmationId); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/SubmitRsvp/SubmitRsvpCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/SubmitRsvp/SubmitRsvpCommandValidator.cs new file mode 100644 index 0000000..9013cc6 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Public/SubmitRsvp/SubmitRsvpCommandValidator.cs @@ -0,0 +1,22 @@ +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; +using Eternelle.Modules.Rsvp.Domain.Shared; +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Public.SubmitRsvp; + +internal sealed class SubmitRsvpCommandValidator : AbstractValidator +{ + public SubmitRsvpCommandValidator() + { + RuleFor(c => c.LookupCode).NotEmpty(); + RuleFor(c => c.Companions).NotNull(); + RuleForEach(c => c.Companions).ChildRules(r => + { + r.RuleFor(x => x.Name).NotEmpty().MaximumLength(PersonName.MaxLength); + r.RuleFor(x => x.DietaryNote).MaximumLength(DietaryNote.MaxLength).When(x => x.DietaryNote is not null); + }); + RuleFor(c => c.DietaryNote).MaximumLength(DietaryNote.MaxLength).When(c => c.DietaryNote is not null); + RuleFor(c => c.Message).MaximumLength(RichDescription.MaxLength).When(c => c.Message is not null); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetHeadcount/GetHeadcountQuery.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetHeadcount/GetHeadcountQuery.cs new file mode 100644 index 0000000..0b8bbea --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetHeadcount/GetHeadcountQuery.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.GetHeadcount; + +public sealed record GetHeadcountQuery(Guid WeddingId) : IQuery; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetHeadcount/GetHeadcountQueryHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetHeadcount/GetHeadcountQueryHandler.cs new file mode 100644 index 0000000..cea3a14 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetHeadcount/GetHeadcountQueryHandler.cs @@ -0,0 +1,64 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.GetHeadcount; + +internal sealed class GetHeadcountQueryHandler(IDbConnectionFactory dbConnectionFactory) + : IQueryHandler +{ + public async Task> Handle(GetHeadcountQuery query, CancellationToken cancellationToken) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); + + const string sql = + $""" + WITH latest_sub AS ( + SELECT DISTINCT ON (guest_id) + id, + guest_id, + attending + FROM rsvp.submissions + WHERE guest_id IS NOT NULL + ORDER BY guest_id, submitted_at DESC + ), + attending_companions AS ( + SELECT submission_id, COUNT(*)::int AS cnt + FROM rsvp.submission_companions + WHERE attending = true + GROUP BY submission_id + ) + SELECT + g.group_name AS {nameof(GroupRow.GroupName)}, + SUM(CASE WHEN ls.id IS NOT NULL AND ls.attending = true THEN 1 ELSE 0 END)::int AS {nameof(GroupRow.Attending)}, + SUM(CASE WHEN ls.id IS NOT NULL AND ls.attending = false THEN 1 ELSE 0 END)::int AS {nameof(GroupRow.Declined)}, + SUM(CASE WHEN ls.id IS NULL THEN 1 ELSE 0 END)::int AS {nameof(GroupRow.NoResponse)}, + SUM(CASE WHEN ls.attending = true THEN 1 + COALESCE(ac.cnt, 0) ELSE 0 END)::int AS {nameof(GroupRow.TotalAttendingPeople)} + FROM rsvp.guests g + LEFT JOIN latest_sub ls ON ls.guest_id = g.id + LEFT JOIN attending_companions ac ON ac.submission_id = ls.id + WHERE g.wedding_id = @WeddingId + GROUP BY g.group_name + ORDER BY g.group_name NULLS FIRST + """; + + List rows = [.. await connection.QueryAsync( + new CommandDefinition(sql, query, cancellationToken: cancellationToken))]; + + return Result.Success(new HeadcountResponse( + rows.Sum(r => r.Attending), + rows.Sum(r => r.Declined), + rows.Sum(r => r.NoResponse), + rows.Sum(r => r.TotalAttendingPeople), + [.. rows.Select(r => new GroupHeadcount(r.GroupName, r.Attending, r.Declined, r.NoResponse))])); + } + + private sealed record GroupRow( + string? GroupName, + int Attending, + int Declined, + int NoResponse, + int TotalAttendingPeople); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetHeadcount/HeadcountResponse.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetHeadcount/HeadcountResponse.cs new file mode 100644 index 0000000..ba6e65a --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetHeadcount/HeadcountResponse.cs @@ -0,0 +1,10 @@ +namespace Eternelle.Modules.Rsvp.Application.Submissions.GetHeadcount; + +public sealed record HeadcountResponse( + int Attending, + int Declined, + int NoResponse, + int TotalAttendingPeople, + IReadOnlyList ByGroup); + +public sealed record GroupHeadcount(string? GroupName, int Attending, int Declined, int NoResponse); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetSubmission/GetSubmissionQuery.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetSubmission/GetSubmissionQuery.cs new file mode 100644 index 0000000..574ae1f --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetSubmission/GetSubmissionQuery.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.GetSubmission; + +public sealed record GetSubmissionQuery(Guid WeddingId, Guid SubmissionId) : IQuery; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetSubmission/GetSubmissionQueryHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetSubmission/GetSubmissionQueryHandler.cs new file mode 100644 index 0000000..25bc2ca --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetSubmission/GetSubmissionQueryHandler.cs @@ -0,0 +1,96 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Submissions; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.GetSubmission; + +internal sealed class GetSubmissionQueryHandler(IDbConnectionFactory dbConnectionFactory) + : IQueryHandler +{ + public async Task> Handle(GetSubmissionQuery query, CancellationToken cancellationToken) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); + + const string submissionSql = + $""" + SELECT + s.id AS {nameof(SubmissionRow.SubmissionId)}, + s.wedding_id AS {nameof(SubmissionRow.WeddingId)}, + s.guest_id AS {nameof(SubmissionRow.GuestId)}, + s.name_snapshot AS {nameof(SubmissionRow.NameSnapshot)}, + s.email_snapshot AS {nameof(SubmissionRow.Email)}, + s.mobile_snapshot AS {nameof(SubmissionRow.MobileNumber)}, + s.attending AS {nameof(SubmissionRow.Attending)}, + s.dietary_note AS {nameof(SubmissionRow.DietaryNote)}, + s.message AS {nameof(SubmissionRow.Message)}, + s.confirmation_id AS {nameof(SubmissionRow.ConfirmationId)}, + s.ip_address AS {nameof(SubmissionRow.IpAddress)}, + s.source AS {nameof(SubmissionRow.Source)}, + s.internal_note AS {nameof(SubmissionRow.InternalNote)}, + s.submitted_at AS {nameof(SubmissionRow.SubmittedAtUtc)} + FROM rsvp.submissions s + WHERE s.id = @SubmissionId AND s.wedding_id = @WeddingId + """; + + SubmissionRow? submission = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(submissionSql, query, cancellationToken: cancellationToken)); + + if (submission is null) + { + return Result.Failure( + RsvpSubmissionErrors.NotFound(new SubmissionId(query.SubmissionId))); + } + + const string companionSql = + $""" + SELECT + c.id AS {nameof(SubmissionCompanionResponse.CompanionId)}, + c.name AS {nameof(SubmissionCompanionResponse.Name)}, + c.attending AS {nameof(SubmissionCompanionResponse.Attending)}, + c.dietary_note AS {nameof(SubmissionCompanionResponse.DietaryNote)}, + c.display_order AS {nameof(SubmissionCompanionResponse.DisplayOrder)} + FROM rsvp.submission_companions c + WHERE c.submission_id = @SubmissionId + ORDER BY c.display_order + """; + + IReadOnlyList companions = [.. await connection.QueryAsync( + new CommandDefinition(companionSql, new { query.SubmissionId }, cancellationToken: cancellationToken))]; + + return Result.Success(new SubmissionResponse( + submission.SubmissionId, + submission.WeddingId, + submission.GuestId, + submission.NameSnapshot, + submission.Email, + submission.MobileNumber, + submission.Attending, + submission.DietaryNote, + submission.Message, + submission.ConfirmationId, + submission.IpAddress, + submission.Source, + submission.InternalNote, + submission.SubmittedAtUtc, + companions)); + } + + private sealed record SubmissionRow( + Guid SubmissionId, + Guid WeddingId, + Guid? GuestId, + string NameSnapshot, + string? Email, + string? MobileNumber, + bool Attending, + string? DietaryNote, + string? Message, + Guid ConfirmationId, + string? IpAddress, + RsvpSource Source, + string? InternalNote, + DateTime SubmittedAtUtc); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetSubmission/SubmissionResponse.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetSubmission/SubmissionResponse.cs new file mode 100644 index 0000000..8286420 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/GetSubmission/SubmissionResponse.cs @@ -0,0 +1,27 @@ +using Eternelle.Modules.Rsvp.Domain.Submissions; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.GetSubmission; + +public sealed record SubmissionResponse( + Guid SubmissionId, + Guid WeddingId, + Guid? GuestId, + string NameSnapshot, + string? Email, + string? MobileNumber, + bool Attending, + string? DietaryNote, + string? Message, + Guid ConfirmationId, + string? IpAddress, + RsvpSource Source, + string? InternalNote, + DateTime SubmittedAtUtc, + IReadOnlyList Companions); + +public sealed record SubmissionCompanionResponse( + Guid CompanionId, + string Name, + bool Attending, + string? DietaryNote, + int DisplayOrder); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/ListSubmissions/ListSubmissionsQuery.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/ListSubmissions/ListSubmissionsQuery.cs new file mode 100644 index 0000000..3afef8a --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/ListSubmissions/ListSubmissionsQuery.cs @@ -0,0 +1,11 @@ +using Eternelle.Common.Application.Messaging; +using Eternelle.Modules.Rsvp.Domain.Submissions; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.ListSubmissions; + +public sealed record ListSubmissionsQuery( + Guid WeddingId, + bool? Attending, + RsvpSource? Source, + int Skip = 0, + int Take = 50) : IQuery>; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/ListSubmissions/ListSubmissionsQueryHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/ListSubmissions/ListSubmissionsQueryHandler.cs new file mode 100644 index 0000000..e724d32 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/ListSubmissions/ListSubmissionsQueryHandler.cs @@ -0,0 +1,41 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.ListSubmissions; + +internal sealed class ListSubmissionsQueryHandler(IDbConnectionFactory dbConnectionFactory) + : IQueryHandler> +{ + public async Task>> Handle( + ListSubmissionsQuery query, CancellationToken cancellationToken) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); + + const string sql = + $""" + SELECT + s.id AS {nameof(SubmissionSummaryResponse.SubmissionId)}, + s.guest_id AS {nameof(SubmissionSummaryResponse.GuestId)}, + s.name_snapshot AS {nameof(SubmissionSummaryResponse.NameSnapshot)}, + s.attending AS {nameof(SubmissionSummaryResponse.Attending)}, + (SELECT COUNT(*)::int FROM rsvp.submission_companions sc WHERE sc.submission_id = s.id) + AS {nameof(SubmissionSummaryResponse.CompanionCount)}, + s.source AS {nameof(SubmissionSummaryResponse.Source)}, + s.submitted_at AS {nameof(SubmissionSummaryResponse.SubmittedAtUtc)} + FROM rsvp.submissions s + WHERE s.wedding_id = @WeddingId + AND (@Attending IS NULL OR s.attending = @Attending) + AND (@Source IS NULL OR s.source = @Source) + ORDER BY s.submitted_at DESC + LIMIT @Take OFFSET @Skip + """; + + IEnumerable rows = await connection.QueryAsync( + new CommandDefinition(sql, query, cancellationToken: cancellationToken)); + + return Result.Success>([.. rows]); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/ListSubmissions/SubmissionSummaryResponse.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/ListSubmissions/SubmissionSummaryResponse.cs new file mode 100644 index 0000000..9688773 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/ListSubmissions/SubmissionSummaryResponse.cs @@ -0,0 +1,12 @@ +using Eternelle.Modules.Rsvp.Domain.Submissions; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.ListSubmissions; + +public sealed record SubmissionSummaryResponse( + Guid SubmissionId, + Guid? GuestId, + string NameSnapshot, + bool Attending, + int CompanionCount, + RsvpSource Source, + DateTime SubmittedAtUtc); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/RsvpSubmittedDomainEventHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/RsvpSubmittedDomainEventHandler.cs new file mode 100644 index 0000000..062ee41 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/RsvpSubmittedDomainEventHandler.cs @@ -0,0 +1,23 @@ +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Application.Messaging; +using Eternelle.Modules.Rsvp.Domain.Submissions; +using Eternelle.Modules.Rsvp.IntegrationEvents.Submissions; + +namespace Eternelle.Modules.Rsvp.Application.Submissions; + +internal sealed class RsvpSubmittedDomainEventHandler(IEventBus eventBus) + : DomainEventHandler +{ + public override Task Handle(RsvpSubmittedDomainEvent domainEvent, CancellationToken cancellationToken = default) => + eventBus.PublishAsync( + new RsvpSubmittedIntegrationEvent( + domainEvent.Id, + domainEvent.OccurredOnUtc, + domainEvent.SubmissionId.Value, + domainEvent.WeddingId.Value, + domainEvent.GuestId.Value, + domainEvent.Attending, + domainEvent.CompanionCount, + domainEvent.SubmittedAtUtc), + cancellationToken); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/SubmitOnBehalf/SubmitOnBehalfCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/SubmitOnBehalf/SubmitOnBehalfCommand.cs new file mode 100644 index 0000000..96a9443 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/SubmitOnBehalf/SubmitOnBehalfCommand.cs @@ -0,0 +1,14 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.SubmitOnBehalf; + +public sealed record CompanionInput(string Name, bool Attending, string? DietaryNote); + +public sealed record SubmitOnBehalfCommand( + Guid WeddingId, + Guid GuestId, + bool Attending, + string? DietaryNote, + string? Message, + IReadOnlyList Companions, + string InternalNote) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/SubmitOnBehalf/SubmitOnBehalfCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/SubmitOnBehalf/SubmitOnBehalfCommandHandler.cs new file mode 100644 index 0000000..1e59956 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/SubmitOnBehalf/SubmitOnBehalfCommandHandler.cs @@ -0,0 +1,120 @@ +using System.Net; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Shared; +using Eternelle.Modules.Rsvp.Domain.Submissions; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.SubmitOnBehalf; + +internal sealed class SubmitOnBehalfCommandHandler( + IGuestRepository guestRepository, + IWeddingProjection weddingProjection, + IRsvpSubmissionRepository submissionRepository, + IUnitOfWork unitOfWork, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task> Handle(SubmitOnBehalfCommand command, CancellationToken cancellationToken) + { + var guestId = new GuestId(command.GuestId); + Guest? guest = await guestRepository.GetByIdAsync(guestId, cancellationToken); + if (guest is null || guest.WeddingId != new WeddingId(command.WeddingId)) + { + return Result.Failure(GuestErrors.NotFound(guestId)); + } + + Wedding? wedding = await weddingProjection.GetAsync(guest.WeddingId, cancellationToken); + if (wedding is null) + { + return Result.Failure(Error.NotFound("Rsvp.Wedding.NotFound", "Wedding was not found.")); + } + + Result nameSnapshotResult = PersonName.Create( + $"{guest.FirstName.Value} {guest.LastName.Value}"); + if (nameSnapshotResult.IsFailure) + { + return Result.Failure(nameSnapshotResult.Error); + } + PersonName nameSnapshot = nameSnapshotResult.Value; + + DietaryNote? dietary = null; + if (command.DietaryNote is not null) + { + Result dietaryResult = DietaryNote.Create(command.DietaryNote); + if (dietaryResult.IsFailure) + { + return Result.Failure(dietaryResult.Error); + } + dietary = dietaryResult.Value; + } + + RichDescription? message = null; + if (command.Message is not null) + { + Result messageResult = RichDescription.Create(command.Message); + if (messageResult.IsFailure) + { + return Result.Failure(messageResult.Error); + } + message = messageResult.Value; + } + + var companionList = new List<(PersonName name, bool attending, DietaryNote? dietary)>(); + foreach (CompanionInput c in command.Companions) + { + Result nameResult = PersonName.Create(c.Name); + if (nameResult.IsFailure) + { + return Result.Failure(nameResult.Error); + } + + DietaryNote? companionDietary = null; + if (c.DietaryNote is not null) + { + Result companionDietaryResult = DietaryNote.Create(c.DietaryNote); + if (companionDietaryResult.IsFailure) + { + return Result.Failure(companionDietaryResult.Error); + } + companionDietary = companionDietaryResult.Value; + } + + companionList.Add((nameResult.Value, c.Attending, companionDietary)); + } + + Result noteResult = InternalNote.Create(command.InternalNote); + if (noteResult.IsFailure) + { + return Result.Failure(noteResult.Error); + } + + Result result = RsvpSubmission.Create( + guest, + nameSnapshot, + guest.Email, + guest.MobileNumber, + command.Attending, + dietary, + message, + companionList, + ipAddress: null, + RsvpSource.CoupleOnBehalf, + noteResult.Value, + clock.UtcNow); + + if (result.IsFailure) + { + return Result.Failure(result.Error); + } + + submissionRepository.Insert(result.Value); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(result.Value.ConfirmationId); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/SubmitOnBehalf/SubmitOnBehalfCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/SubmitOnBehalf/SubmitOnBehalfCommandValidator.cs new file mode 100644 index 0000000..5b43272 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Submissions/SubmitOnBehalf/SubmitOnBehalfCommandValidator.cs @@ -0,0 +1,24 @@ +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; +using Eternelle.Modules.Rsvp.Domain.Shared; +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Submissions.SubmitOnBehalf; + +internal sealed class SubmitOnBehalfCommandValidator : AbstractValidator +{ + public SubmitOnBehalfCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + RuleFor(c => c.GuestId).NotEmpty(); + RuleFor(c => c.InternalNote).NotEmpty().MaximumLength(InternalNote.MaxLength); + RuleFor(c => c.Companions).NotNull(); + RuleForEach(c => c.Companions).ChildRules(r => + { + r.RuleFor(x => x.Name).NotEmpty().MaximumLength(PersonName.MaxLength); + r.RuleFor(x => x.DietaryNote).MaximumLength(DietaryNote.MaxLength).When(x => x.DietaryNote is not null); + }); + RuleFor(c => c.DietaryNote).MaximumLength(DietaryNote.MaxLength).When(c => c.DietaryNote is not null); + RuleFor(c => c.Message).MaximumLength(RichDescription.MaxLength).When(c => c.Message is not null); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/ClearRsvpDeadline/ClearRsvpDeadlineCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/ClearRsvpDeadline/ClearRsvpDeadlineCommand.cs new file mode 100644 index 0000000..5da1728 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/ClearRsvpDeadline/ClearRsvpDeadlineCommand.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Weddings.ClearRsvpDeadline; + +public sealed record ClearRsvpDeadlineCommand(Guid WeddingId) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/ClearRsvpDeadline/ClearRsvpDeadlineCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/ClearRsvpDeadline/ClearRsvpDeadlineCommandHandler.cs new file mode 100644 index 0000000..6cd190c --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/ClearRsvpDeadline/ClearRsvpDeadlineCommandHandler.cs @@ -0,0 +1,25 @@ +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Weddings.ClearRsvpDeadline; + +internal sealed class ClearRsvpDeadlineCommandHandler( + IWeddingProjection projection, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task Handle(ClearRsvpDeadlineCommand command, CancellationToken cancellationToken) + { + Wedding? wedding = await projection.GetAsync(new WeddingId(command.WeddingId), cancellationToken); + if (wedding is null) + { + return Result.Failure(Error.NotFound("Rsvp.Wedding.NotFound", "Wedding was not found.")); + } + + wedding.SetRsvpDeadline(null, clock.UtcNow); + await projection.UpdateAsync(wedding, cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/ClearRsvpDeadline/ClearRsvpDeadlineCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/ClearRsvpDeadline/ClearRsvpDeadlineCommandValidator.cs new file mode 100644 index 0000000..9a8ab3e --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/ClearRsvpDeadline/ClearRsvpDeadlineCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Weddings.ClearRsvpDeadline; + +internal sealed class ClearRsvpDeadlineCommandValidator : AbstractValidator +{ + public ClearRsvpDeadlineCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/SetRsvpDeadline/SetRsvpDeadlineCommand.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/SetRsvpDeadline/SetRsvpDeadlineCommand.cs new file mode 100644 index 0000000..98df34c --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/SetRsvpDeadline/SetRsvpDeadlineCommand.cs @@ -0,0 +1,5 @@ +using Eternelle.Common.Application.Messaging; + +namespace Eternelle.Modules.Rsvp.Application.Weddings.SetRsvpDeadline; + +public sealed record SetRsvpDeadlineCommand(Guid WeddingId, DateOnly Deadline) : ICommand; diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/SetRsvpDeadline/SetRsvpDeadlineCommandHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/SetRsvpDeadline/SetRsvpDeadlineCommandHandler.cs new file mode 100644 index 0000000..c3a7cb3 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/SetRsvpDeadline/SetRsvpDeadlineCommandHandler.cs @@ -0,0 +1,25 @@ +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Application.Weddings.SetRsvpDeadline; + +internal sealed class SetRsvpDeadlineCommandHandler( + IWeddingProjection projection, + IDateTimeProvider clock) : ICommandHandler +{ + public async Task Handle(SetRsvpDeadlineCommand command, CancellationToken cancellationToken) + { + Wedding? wedding = await projection.GetAsync(new WeddingId(command.WeddingId), cancellationToken); + if (wedding is null) + { + return Result.Failure(Error.NotFound("Rsvp.Wedding.NotFound", "Wedding was not found.")); + } + + wedding.SetRsvpDeadline(command.Deadline, clock.UtcNow); + await projection.UpdateAsync(wedding, cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/SetRsvpDeadline/SetRsvpDeadlineCommandValidator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/SetRsvpDeadline/SetRsvpDeadlineCommandValidator.cs new file mode 100644 index 0000000..c7bff7b --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Application/Weddings/SetRsvpDeadline/SetRsvpDeadlineCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Eternelle.Modules.Rsvp.Application.Weddings.SetRsvpDeadline; + +internal sealed class SetRsvpDeadlineCommandValidator : AbstractValidator +{ + public SetRsvpDeadlineCommandValidator() + { + RuleFor(c => c.WeddingId).NotEmpty(); + RuleFor(c => c.Deadline) + .Must(d => d >= DateOnly.FromDateTime(DateTime.UtcNow.Date)) + .WithMessage("Deadline must be today or in the future."); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/AssemblyInfo.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/AssemblyInfo.cs new file mode 100644 index 0000000..9fd09eb --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Eternelle.Modules.Rsvp.Infrastructure")] diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Eternelle.Modules.Rsvp.Domain.csproj b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Eternelle.Modules.Rsvp.Domain.csproj new file mode 100644 index 0000000..081262f --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Eternelle.Modules.Rsvp.Domain.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/Guest.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/Guest.cs new file mode 100644 index 0000000..183d346 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/Guest.cs @@ -0,0 +1,141 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; +using Eternelle.Modules.Rsvp.Domain.Shared; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public sealed class Guest : Entity +{ + private Guest() { } // EF constructor + + public GuestId Id { get; private set; } + public WeddingId WeddingId { get; private set; } + public PersonFirstName FirstName { get; private set; } = null!; + public PersonLastName LastName { get; private set; } = null!; + public EmailAddress? Email { get; private set; } + public PhoneNumber? MobileNumber { get; private set; } + public LookupCode LookupCode { get; private set; } = null!; + public int AllowedCompanions { get; private set; } + public int? TableNumber { get; private set; } + public GroupLabel? GroupName { get; private set; } + public bool IsLocked { get; private set; } + public DateTime? LockedAtUtc { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + public DateTime UpdatedAtUtc { get; private set; } + + public static Result Create( + WeddingId weddingId, + PersonFirstName firstName, + PersonLastName lastName, + EmailAddress? email, + PhoneNumber? mobileNumber, + LookupCode lookupCode, + int allowedCompanions, + int? tableNumber, + GroupLabel? groupName, + DateTime utcNow) + { + if (allowedCompanions < 0) + { + return Result.Failure(GuestErrors.AllowanceNegative); + } + + var guest = new Guest + { + Id = GuestId.New(), + WeddingId = weddingId, + FirstName = firstName, + LastName = lastName, + Email = email, + MobileNumber = mobileNumber, + LookupCode = lookupCode, + AllowedCompanions = allowedCompanions, + TableNumber = tableNumber, + GroupName = groupName, + IsLocked = false, + LockedAtUtc = null, + CreatedAtUtc = utcNow, + UpdatedAtUtc = utcNow + }; + + guest.Raise(new GuestAddedDomainEvent( + guest.Id, guest.WeddingId, guest.LookupCode.Value, guest.AllowedCompanions)); + + return Result.Success(guest); + } + + public void UpdateContact( + PersonFirstName firstName, PersonLastName lastName, + EmailAddress? email, PhoneNumber? mobileNumber, DateTime utcNow) + { + FirstName = firstName; + LastName = lastName; + Email = email; + MobileNumber = mobileNumber; + UpdatedAtUtc = utcNow; + Raise(new GuestContactUpdatedDomainEvent(Id, WeddingId)); + } + + public Result ChangeAllowedCompanions(int allowedCompanions, DateTime utcNow) + { + if (allowedCompanions < 0) + { + return Result.Failure(GuestErrors.AllowanceNegative); + } + + AllowedCompanions = allowedCompanions; + UpdatedAtUtc = utcNow; + Raise(new GuestAllowanceChangedDomainEvent(Id, WeddingId, allowedCompanions)); + return Result.Success(); + } + + public void UpdatePlacement(int? tableNumber, GroupLabel? groupName, DateTime utcNow) + { + TableNumber = tableNumber; + GroupName = groupName; + UpdatedAtUtc = utcNow; + Raise(new GuestPlacementUpdatedDomainEvent(Id, WeddingId)); + } + + public Result Lock(DateTime utcNow) + { + if (IsLocked) + { + return Result.Failure(GuestErrors.AlreadyLocked); + } + + IsLocked = true; + LockedAtUtc = utcNow; + UpdatedAtUtc = utcNow; + Raise(new GuestLockedDomainEvent(Id, WeddingId, utcNow)); + return Result.Success(); + } + + public Result Unlock(DateTime utcNow) + { + if (!IsLocked) + { + return Result.Failure(GuestErrors.NotLocked); + } + + IsLocked = false; + LockedAtUtc = null; + UpdatedAtUtc = utcNow; + Raise(new GuestUnlockedDomainEvent(Id, WeddingId)); + return Result.Success(); + } + + public void RegenerateLookupCode(LookupCode newCode, DateTime utcNow) + { + LookupCode = newCode; + UpdatedAtUtc = utcNow; + Raise(new GuestLookupCodeRegeneratedDomainEvent(Id, WeddingId, newCode.Value)); + } + + public void MarkRemoved() + { + Raise(new GuestRemovedDomainEvent(Id, WeddingId)); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestAddedDomainEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestAddedDomainEvent.cs new file mode 100644 index 0000000..dcc812c --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestAddedDomainEvent.cs @@ -0,0 +1,16 @@ +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public sealed class GuestAddedDomainEvent( + GuestId guestId, + WeddingId weddingId, + string lookupCode, + int allowedCompanions) : DomainEvent +{ + public GuestId GuestId { get; init; } = guestId; + public WeddingId WeddingId { get; init; } = weddingId; + public string LookupCode { get; init; } = lookupCode; + public int AllowedCompanions { get; init; } = allowedCompanions; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestAllowanceChangedDomainEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestAllowanceChangedDomainEvent.cs new file mode 100644 index 0000000..e9a3a74 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestAllowanceChangedDomainEvent.cs @@ -0,0 +1,11 @@ +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public sealed class GuestAllowanceChangedDomainEvent(GuestId guestId, WeddingId weddingId, int allowedCompanions) : DomainEvent +{ + public GuestId GuestId { get; init; } = guestId; + public WeddingId WeddingId { get; init; } = weddingId; + public int AllowedCompanions { get; init; } = allowedCompanions; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestContactUpdatedDomainEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestContactUpdatedDomainEvent.cs new file mode 100644 index 0000000..7ff7432 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestContactUpdatedDomainEvent.cs @@ -0,0 +1,10 @@ +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public sealed class GuestContactUpdatedDomainEvent(GuestId guestId, WeddingId weddingId) : DomainEvent +{ + public GuestId GuestId { get; init; } = guestId; + public WeddingId WeddingId { get; init; } = weddingId; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestErrors.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestErrors.cs new file mode 100644 index 0000000..7e8fbf2 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestErrors.cs @@ -0,0 +1,21 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public static class GuestErrors +{ + public static Error NotFound(GuestId id) => + Error.NotFound("Rsvp.Guest.NotFound", $"Guest '{id.Value}' was not found."); + + public static readonly Error LookupCodeAlreadyTaken = + Error.Conflict("Rsvp.Guest.LookupCodeAlreadyTaken", "Lookup code is already in use."); + + public static readonly Error AllowanceNegative = + Error.Problem("Rsvp.Guest.AllowanceNegative", "Allowed companions must be zero or greater."); + + public static readonly Error AlreadyLocked = + Error.Problem("Rsvp.Guest.AlreadyLocked", "Guest is already locked."); + + public static readonly Error NotLocked = + Error.Problem("Rsvp.Guest.NotLocked", "Guest is not locked."); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestId.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestId.cs new file mode 100644 index 0000000..79f440d --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestId.cs @@ -0,0 +1,8 @@ +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public readonly record struct GuestId(Guid Value) +{ + public static GuestId New() => new(Guid.CreateVersion7()); + public static GuestId Empty => new(Guid.Empty); + public override string ToString() => Value.ToString(); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestLockedDomainEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestLockedDomainEvent.cs new file mode 100644 index 0000000..01ac062 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestLockedDomainEvent.cs @@ -0,0 +1,11 @@ +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public sealed class GuestLockedDomainEvent(GuestId guestId, WeddingId weddingId, DateTime lockedAtUtc) : DomainEvent +{ + public GuestId GuestId { get; init; } = guestId; + public WeddingId WeddingId { get; init; } = weddingId; + public DateTime LockedAtUtc { get; init; } = lockedAtUtc; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestLookupCodeRegeneratedDomainEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestLookupCodeRegeneratedDomainEvent.cs new file mode 100644 index 0000000..ed7eaf4 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestLookupCodeRegeneratedDomainEvent.cs @@ -0,0 +1,11 @@ +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public sealed class GuestLookupCodeRegeneratedDomainEvent(GuestId guestId, WeddingId weddingId, string lookupCode) : DomainEvent +{ + public GuestId GuestId { get; init; } = guestId; + public WeddingId WeddingId { get; init; } = weddingId; + public string LookupCode { get; init; } = lookupCode; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestPlacementUpdatedDomainEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestPlacementUpdatedDomainEvent.cs new file mode 100644 index 0000000..ca1e182 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestPlacementUpdatedDomainEvent.cs @@ -0,0 +1,10 @@ +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public sealed class GuestPlacementUpdatedDomainEvent(GuestId guestId, WeddingId weddingId) : DomainEvent +{ + public GuestId GuestId { get; init; } = guestId; + public WeddingId WeddingId { get; init; } = weddingId; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestRemovedDomainEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestRemovedDomainEvent.cs new file mode 100644 index 0000000..81a4302 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestRemovedDomainEvent.cs @@ -0,0 +1,10 @@ +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public sealed class GuestRemovedDomainEvent(GuestId guestId, WeddingId weddingId) : DomainEvent +{ + public GuestId GuestId { get; init; } = guestId; + public WeddingId WeddingId { get; init; } = weddingId; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestUnlockedDomainEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestUnlockedDomainEvent.cs new file mode 100644 index 0000000..c6a3179 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/GuestUnlockedDomainEvent.cs @@ -0,0 +1,10 @@ +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public sealed class GuestUnlockedDomainEvent(GuestId guestId, WeddingId weddingId) : DomainEvent +{ + public GuestId GuestId { get; init; } = guestId; + public WeddingId WeddingId { get; init; } = weddingId; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/IGuestRepository.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/IGuestRepository.cs new file mode 100644 index 0000000..7ce92cf --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Guests/IGuestRepository.cs @@ -0,0 +1,14 @@ +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Guests; + +public interface IGuestRepository +{ + Task GetByIdAsync(GuestId id, CancellationToken cancellationToken = default); + Task GetByLookupCodeAsync(string normalizedCode, CancellationToken cancellationToken = default); + Task LookupCodeExistsAsync(string normalizedCode, CancellationToken cancellationToken = default); + Task> ListByWeddingAsync(WeddingId weddingId, CancellationToken cancellationToken = default); + void Insert(Guest guest); + void Update(Guest guest); + void Remove(Guest guest); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/DietaryNote.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/DietaryNote.cs new file mode 100644 index 0000000..bc2ac55 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/DietaryNote.cs @@ -0,0 +1,31 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Rsvp.Domain.Shared; + +public sealed record DietaryNote +{ + public const int MaxLength = 300; + + private DietaryNote(string value) { Value = value; } + public string Value { get; } + + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(DietaryNoteErrors.Empty); + } + + string trimmed = raw.Trim(); + if (trimmed.Length > MaxLength) + { + return Result.Failure(DietaryNoteErrors.TooLong); + } + + return Result.Success(new DietaryNote(trimmed)); + } + + public override string ToString() => Value; + + internal static DietaryNote FromPersistence(string value) => new(value); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/DietaryNoteErrors.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/DietaryNoteErrors.cs new file mode 100644 index 0000000..b56b616 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/DietaryNoteErrors.cs @@ -0,0 +1,13 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Rsvp.Domain.Shared; + +public static class DietaryNoteErrors +{ + public static readonly Error Empty = + Error.Problem("Rsvp.DietaryNote.Empty", "Dietary note must not be empty."); + + public static readonly Error TooLong = + Error.Problem("Rsvp.DietaryNote.TooLong", + $"Dietary note must not exceed {DietaryNote.MaxLength} characters."); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/LookupCode.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/LookupCode.cs new file mode 100644 index 0000000..d334524 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/LookupCode.cs @@ -0,0 +1,39 @@ +using System.Text.RegularExpressions; +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Rsvp.Domain.Shared; + +public sealed partial record LookupCode +{ + public const int MaxLength = 8; + + /// Unambiguous alphabet — no 0/O/1/I/L. 32 chars → 40 bits at length 8. + public const string Alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"; + + private LookupCode(string value) { Value = value; } + public string Value { get; } + + /// Validates and normalizes (uppercases, strips dashes/whitespace). + public static Result Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return Result.Failure(LookupCodeErrors.Empty); + } + + string normalized = raw.Replace("-", string.Empty).Trim().ToUpperInvariant(); + if (normalized.Length != MaxLength || !LookupCodeRegex().IsMatch(normalized)) + { + return Result.Failure(LookupCodeErrors.InvalidFormat); + } + + return Result.Success(new LookupCode(normalized)); + } + + public override string ToString() => Value; + + internal static LookupCode FromPersistence(string value) => new(value); + + [GeneratedRegex("^[2-9A-HJ-NP-Z]{8}$")] + private static partial Regex LookupCodeRegex(); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/LookupCodeErrors.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/LookupCodeErrors.cs new file mode 100644 index 0000000..cf6653e --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Shared/LookupCodeErrors.cs @@ -0,0 +1,13 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Rsvp.Domain.Shared; + +public static class LookupCodeErrors +{ + public static readonly Error Empty = + Error.Problem("Rsvp.LookupCode.Empty", "Lookup code is required."); + + public static readonly Error InvalidFormat = + Error.Problem("Rsvp.LookupCode.InvalidFormat", + "Lookup code must be 8 characters from the unambiguous alphabet."); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/CompanionId.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/CompanionId.cs new file mode 100644 index 0000000..5cdd42d --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/CompanionId.cs @@ -0,0 +1,7 @@ +namespace Eternelle.Modules.Rsvp.Domain.Submissions; + +public readonly record struct CompanionId(Guid Value) +{ + public static CompanionId New() => new(Guid.CreateVersion7()); + public static CompanionId Empty => new(Guid.Empty); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/IRsvpSubmissionRepository.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/IRsvpSubmissionRepository.cs new file mode 100644 index 0000000..356568b --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/IRsvpSubmissionRepository.cs @@ -0,0 +1,14 @@ +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Submissions; + +public interface IRsvpSubmissionRepository +{ + Task GetByIdAsync(SubmissionId id, CancellationToken cancellationToken = default); + Task GetLatestByGuestAsync(GuestId guestId, CancellationToken cancellationToken = default); + Task> ListByWeddingAsync( + WeddingId weddingId, CancellationToken cancellationToken = default); + void Insert(RsvpSubmission submission); + Task NullifyGuestIdAsync(GuestId guestId, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSource.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSource.cs new file mode 100644 index 0000000..cf881a0 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSource.cs @@ -0,0 +1,7 @@ +namespace Eternelle.Modules.Rsvp.Domain.Submissions; + +public enum RsvpSource +{ + GuestSelf = 0, + CoupleOnBehalf = 1 +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSubmission.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSubmission.cs new file mode 100644 index 0000000..4df3f46 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSubmission.cs @@ -0,0 +1,97 @@ +using System.Net; +using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Shared; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Submissions; + +public sealed class RsvpSubmission : Entity +{ + public const int IpAddressMaxLength = 45; // max IPv6 string length + + private readonly List _companions = []; + + private RsvpSubmission() { } + + public SubmissionId Id { get; private set; } + public WeddingId WeddingId { get; private set; } + public GuestId? GuestId { get; private set; } + public PersonName NameSnapshot { get; private set; } = null!; + public EmailAddress? EmailSnapshot { get; private set; } + public PhoneNumber? MobileSnapshot { get; private set; } + public bool Attending { get; private set; } + public DietaryNote? DietaryNote { get; private set; } + public RichDescription? Message { get; private set; } + public Guid ConfirmationId { get; private set; } + public IPAddress? IpAddress { get; private set; } + public RsvpSource Source { get; private set; } + public InternalNote? InternalNote { get; private set; } + public DateTime SubmittedAtUtc { get; private set; } + + public IReadOnlyCollection Companions => _companions.AsReadOnly(); + + public static Result Create( + Guest guest, + PersonName nameSnapshot, + EmailAddress? emailSnapshot, + PhoneNumber? mobileSnapshot, + bool attending, + DietaryNote? dietaryNote, + RichDescription? message, + IReadOnlyList<(PersonName name, bool attending, DietaryNote? dietary)> companions, + IPAddress? ipAddress, + RsvpSource source, + InternalNote? internalNote, + DateTime utcNow) + { + if (source == RsvpSource.CoupleOnBehalf && internalNote is null) + { + return Result.Failure( + RsvpSubmissionErrors.InternalNoteRequiredOnBehalf); + } + + if (!attending && companions.Count > 0) + { + return Result.Failure(RsvpSubmissionErrors.CompanionsOnDecline); + } + + if (companions.Count > guest.AllowedCompanions) + { + return Result.Failure(RsvpSubmissionErrors.TooManyCompanions); + } + + var submission = new RsvpSubmission + { + Id = SubmissionId.New(), + WeddingId = guest.WeddingId, + GuestId = guest.Id, + NameSnapshot = nameSnapshot, + EmailSnapshot = emailSnapshot, + MobileSnapshot = mobileSnapshot, + Attending = attending, + DietaryNote = dietaryNote, + Message = message, + ConfirmationId = Guid.CreateVersion7(), + IpAddress = ipAddress, + Source = source, + InternalNote = internalNote, + SubmittedAtUtc = utcNow + }; + + int order = 0; + foreach ((PersonName name, bool attending, DietaryNote? dietary) c in companions) + { + submission._companions.Add( + SubmissionCompanion.Create(submission.Id, c.name, c.attending, c.dietary, order++)); + } + + submission.Raise(new RsvpSubmittedDomainEvent( + submission.Id, submission.WeddingId, guest.Id, + attending, submission._companions.Count, utcNow)); + + return Result.Success(submission); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSubmissionErrors.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSubmissionErrors.cs new file mode 100644 index 0000000..1df5c74 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSubmissionErrors.cs @@ -0,0 +1,24 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Rsvp.Domain.Submissions; + +public static class RsvpSubmissionErrors +{ + public static Error NotFound(SubmissionId id) => + Error.NotFound("Rsvp.Submission.NotFound", $"Submission '{id.Value}' was not found."); + + public static readonly Error TooManyCompanions = + Error.Problem("Rsvp.Submission.TooManyCompanions", + "Companion count exceeds the guest's allowance."); + + public static readonly Error CompanionsOnDecline = + Error.Problem("Rsvp.Submission.CompanionsOnDecline", + "Companions cannot be listed when the guest is not attending."); + + public static readonly Error WeddingClosed = + Error.Problem("Rsvp.Submission.WeddingClosed", "RSVP is no longer accepting submissions."); + + public static readonly Error InternalNoteRequiredOnBehalf = + Error.Problem("Rsvp.Submission.InternalNoteRequiredOnBehalf", + "An internal note is required when submitting on behalf of a guest."); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSubmittedDomainEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSubmittedDomainEvent.cs new file mode 100644 index 0000000..9475a21 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/RsvpSubmittedDomainEvent.cs @@ -0,0 +1,21 @@ +using Eternelle.Common.Domain; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Weddings; + +namespace Eternelle.Modules.Rsvp.Domain.Submissions; + +public sealed class RsvpSubmittedDomainEvent( + SubmissionId submissionId, + WeddingId weddingId, + GuestId guestId, + bool attending, + int companionCount, + DateTime submittedAtUtc) : DomainEvent +{ + public SubmissionId SubmissionId { get; init; } = submissionId; + public WeddingId WeddingId { get; init; } = weddingId; + public GuestId GuestId { get; init; } = guestId; + public bool Attending { get; init; } = attending; + public int CompanionCount { get; init; } = companionCount; + public DateTime SubmittedAtUtc { get; init; } = submittedAtUtc; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/SubmissionCompanion.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/SubmissionCompanion.cs new file mode 100644 index 0000000..a69ede3 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/SubmissionCompanion.cs @@ -0,0 +1,32 @@ +using Eternelle.Common.Domain.People; +using Eternelle.Modules.Rsvp.Domain.Shared; + +namespace Eternelle.Modules.Rsvp.Domain.Submissions; + +public sealed class SubmissionCompanion +{ + private SubmissionCompanion() { } + + public CompanionId Id { get; private set; } + public SubmissionId SubmissionId { get; private set; } + public PersonName Name { get; private set; } = null!; + public bool Attending { get; private set; } + public DietaryNote? DietaryNote { get; private set; } + public int DisplayOrder { get; private set; } + + internal static SubmissionCompanion Create( + SubmissionId submissionId, + PersonName name, + bool attending, + DietaryNote? dietaryNote, + int displayOrder) => + new() + { + Id = CompanionId.New(), + SubmissionId = submissionId, + Name = name, + Attending = attending, + DietaryNote = dietaryNote, + DisplayOrder = displayOrder + }; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/SubmissionId.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/SubmissionId.cs new file mode 100644 index 0000000..c74e131 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Submissions/SubmissionId.cs @@ -0,0 +1,8 @@ +namespace Eternelle.Modules.Rsvp.Domain.Submissions; + +public readonly record struct SubmissionId(Guid Value) +{ + public static SubmissionId New() => new(Guid.CreateVersion7()); + public static SubmissionId Empty => new(Guid.Empty); + public override string ToString() => Value.ToString(); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/IWeddingProjection.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/IWeddingProjection.cs new file mode 100644 index 0000000..4f640de --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/IWeddingProjection.cs @@ -0,0 +1,8 @@ +namespace Eternelle.Modules.Rsvp.Domain.Weddings; + +public interface IWeddingProjection +{ + Task GetAsync(WeddingId id, CancellationToken cancellationToken = default); + Task UpsertAsync(Wedding wedding, CancellationToken cancellationToken = default); + Task UpdateAsync(Wedding wedding, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/Wedding.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/Wedding.cs new file mode 100644 index 0000000..a5b6ebc --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/Wedding.cs @@ -0,0 +1,46 @@ +namespace Eternelle.Modules.Rsvp.Domain.Weddings; + +public sealed class Wedding +{ + private Wedding() { } + + public WeddingId Id { get; private set; } + public DateOnly WeddingDate { get; private set; } + public DateOnly? RsvpDeadline { get; private set; } + public bool IsClosed { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + public DateTime UpdatedAtUtc { get; private set; } + + public static Wedding Create(WeddingId id, DateOnly weddingDate, DateTime utcNow) => + new() + { + Id = id, + WeddingDate = weddingDate, + RsvpDeadline = null, + IsClosed = false, + CreatedAtUtc = utcNow, + UpdatedAtUtc = utcNow + }; + + public void SetRsvpDeadline(DateOnly? deadline, DateTime utcNow) + { + RsvpDeadline = deadline; + UpdatedAtUtc = utcNow; + } + + public void Close(DateTime utcNow) + { + IsClosed = true; + UpdatedAtUtc = utcNow; + } + + public void UpdateWeddingDate(DateOnly date, DateTime utcNow) + { + WeddingDate = date; + UpdatedAtUtc = utcNow; + } + + /// True when neither the wedding is closed nor the deadline has passed. + public bool IsAcceptingSubmissions(DateOnly today) => + !IsClosed && (RsvpDeadline is null || today <= RsvpDeadline); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/WeddingErrors.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/WeddingErrors.cs new file mode 100644 index 0000000..20c8ac9 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/WeddingErrors.cs @@ -0,0 +1,9 @@ +using Eternelle.Common.Domain; + +namespace Eternelle.Modules.Rsvp.Domain.Weddings; + +public static class WeddingErrors +{ + public static Error NotFound(WeddingId id) => + Error.NotFound("Rsvp.Wedding.NotFound", $"Wedding '{id.Value}' was not found."); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/WeddingId.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/WeddingId.cs new file mode 100644 index 0000000..9a74b67 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Domain/Weddings/WeddingId.cs @@ -0,0 +1,7 @@ +namespace Eternelle.Modules.Rsvp.Domain.Weddings; + +public readonly record struct WeddingId(Guid Value) +{ + public static WeddingId Empty => new(Guid.Empty); + public override string ToString() => Value.ToString(); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/CompanionIdConverter.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/CompanionIdConverter.cs new file mode 100644 index 0000000..7df59cc --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/CompanionIdConverter.cs @@ -0,0 +1,6 @@ +using Eternelle.Modules.Rsvp.Domain.Submissions; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Database.Converters; + +internal sealed class CompanionIdConverter() : ValueConverter(id => id.Value, value => new CompanionId(value)); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/GuestIdConverter.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/GuestIdConverter.cs new file mode 100644 index 0000000..ccf4e69 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/GuestIdConverter.cs @@ -0,0 +1,6 @@ +using Eternelle.Modules.Rsvp.Domain.Guests; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Database.Converters; + +internal sealed class GuestIdConverter() : ValueConverter(id => id.Value, value => new GuestId(value)); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/SubmissionIdConverter.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/SubmissionIdConverter.cs new file mode 100644 index 0000000..c129d7e --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/SubmissionIdConverter.cs @@ -0,0 +1,6 @@ +using Eternelle.Modules.Rsvp.Domain.Submissions; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Database.Converters; + +internal sealed class SubmissionIdConverter() : ValueConverter(id => id.Value, value => new SubmissionId(value)); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/WeddingIdConverter.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/WeddingIdConverter.cs new file mode 100644 index 0000000..2d20968 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Converters/WeddingIdConverter.cs @@ -0,0 +1,6 @@ +using Eternelle.Modules.Rsvp.Domain.Weddings; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Database.Converters; + +internal sealed class WeddingIdConverter() : ValueConverter(id => id.Value, value => new WeddingId(value)); diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/RsvpDbContext.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/RsvpDbContext.cs new file mode 100644 index 0000000..1373045 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/RsvpDbContext.cs @@ -0,0 +1,43 @@ +using Eternelle.Common.Infrastructure.Inbox; +using Eternelle.Common.Infrastructure.Outbox; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Submissions; +using Eternelle.Modules.Rsvp.Domain.Weddings; +using Eternelle.Modules.Rsvp.Infrastructure.Database.Converters; +using Eternelle.Modules.Rsvp.Infrastructure.Guests; +using Eternelle.Modules.Rsvp.Infrastructure.Submissions; +using Eternelle.Modules.Rsvp.Infrastructure.Weddings; +using Microsoft.EntityFrameworkCore; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Database; + +public sealed class RsvpDbContext(DbContextOptions options) + : DbContext(options), IUnitOfWork +{ + internal DbSet Weddings { get; set; } + internal DbSet Guests { get; set; } + internal DbSet Submissions { get; set; } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(Schemas.Rsvp); + + modelBuilder.ApplyConfiguration(new OutboxMessageConfiguration()); + modelBuilder.ApplyConfiguration(new OutboxMessageConsumerConfiguration()); + modelBuilder.ApplyConfiguration(new InboxMessageConfiguration()); + modelBuilder.ApplyConfiguration(new InboxMessageConsumerConfiguration()); + + modelBuilder.ApplyConfiguration(new WeddingConfiguration()); + modelBuilder.ApplyConfiguration(new GuestConfiguration()); + modelBuilder.ApplyConfiguration(new RsvpSubmissionConfiguration()); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/RsvpDbContextFactory.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/RsvpDbContextFactory.cs new file mode 100644 index 0000000..2c3ee5c --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/RsvpDbContextFactory.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Database; + +internal sealed class RsvpDbContextFactory : IDesignTimeDbContextFactory +{ + public RsvpDbContext CreateDbContext(string[] args) + { +#pragma warning disable S2068 + DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder() + .UseNpgsql( + "Host=localhost;Port=5432;Database=eternelle;Username=postgres;Password=postgres", + npgsql => npgsql.MigrationsHistoryTable( + HistoryRepository.DefaultTableName, Schemas.Rsvp)) + .UseSnakeCaseNamingConvention(); +#pragma warning restore S2068 + + return new RsvpDbContext(optionsBuilder.Options); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Schemas.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Schemas.cs new file mode 100644 index 0000000..2c029d5 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Database/Schemas.cs @@ -0,0 +1,6 @@ +namespace Eternelle.Modules.Rsvp.Infrastructure.Database; + +internal static class Schemas +{ + public const string Rsvp = "rsvp"; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Eternelle.Modules.Rsvp.Infrastructure.csproj b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Eternelle.Modules.Rsvp.Infrastructure.csproj new file mode 100644 index 0000000..9881cc2 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Eternelle.Modules.Rsvp.Infrastructure.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Guests/GuestConfiguration.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Guests/GuestConfiguration.cs new file mode 100644 index 0000000..f568c50 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Guests/GuestConfiguration.cs @@ -0,0 +1,59 @@ +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Guests; + +internal sealed class GuestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder b) + { + b.ToTable("guests"); + b.HasKey(g => g.Id); + + b.Property(g => g.WeddingId).IsRequired(); + b.HasIndex(g => g.WeddingId); + + b.Property(g => g.FirstName) + .HasConversion(v => v.Value, v => PersonFirstName.FromPersistence(v)) + .IsRequired().HasMaxLength(PersonFirstName.MaxLength); + b.Property(g => g.LastName) + .HasConversion(v => v.Value, v => PersonLastName.FromPersistence(v)) + .IsRequired().HasMaxLength(PersonLastName.MaxLength); + + b.Property(g => g.Email) + .HasConversion( + v => v != null ? v.Value : null, + v => v != null ? EmailAddress.FromPersistence(v) : null) + .HasMaxLength(EmailAddress.MaxLength); + + b.Property(g => g.MobileNumber) + .HasConversion( + v => v != null ? v.Value : null, + v => v != null ? PhoneNumber.FromPersistence(v) : null) + .HasMaxLength(PhoneNumber.MaxLength); + + b.Property(g => g.LookupCode) + .HasConversion(v => v.Value, v => LookupCode.FromPersistence(v)) + .IsRequired().HasMaxLength(LookupCode.MaxLength); + b.HasIndex(g => g.LookupCode).IsUnique(); + + b.Property(g => g.AllowedCompanions).IsRequired(); + b.ToTable(t => t.HasCheckConstraint("ck_guests_allowed_companions", "allowed_companions >= 0")); + b.Property(g => g.TableNumber); + b.Property(g => g.GroupName) + .HasConversion( + v => v != null ? v.Value : null, + v => v != null ? GroupLabel.FromPersistence(v) : null) + .HasMaxLength(GroupLabel.MaxLength); + + b.Property(g => g.IsLocked).IsRequired(); + b.Property(g => g.LockedAtUtc); + + b.Property(g => g.CreatedAtUtc).HasColumnName("created_at").IsRequired(); + b.Property(g => g.UpdatedAtUtc).HasColumnName("updated_at").IsRequired(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Guests/GuestRepository.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Guests/GuestRepository.cs new file mode 100644 index 0000000..69c8d3d --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Guests/GuestRepository.cs @@ -0,0 +1,28 @@ +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Weddings; +using Eternelle.Modules.Rsvp.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Guests; + +internal sealed class GuestRepository(RsvpDbContext context) : IGuestRepository +{ + public Task GetByIdAsync(GuestId id, CancellationToken cancellationToken = default) => + context.Guests.SingleOrDefaultAsync(g => g.Id == id, cancellationToken); + + public Task GetByLookupCodeAsync(string normalizedCode, CancellationToken cancellationToken = default) => + context.Guests.SingleOrDefaultAsync(g => g.LookupCode.Value == normalizedCode, cancellationToken); + + public Task LookupCodeExistsAsync(string normalizedCode, CancellationToken cancellationToken = default) => + context.Guests.AnyAsync(g => g.LookupCode.Value == normalizedCode, cancellationToken); + + public async Task> ListByWeddingAsync( + WeddingId weddingId, CancellationToken cancellationToken = default) => + await context.Guests.Where(g => g.WeddingId == weddingId) + .OrderBy(g => g.LastName.Value).ThenBy(g => g.FirstName.Value) + .ToListAsync(cancellationToken); + + public void Insert(Guest guest) => context.Guests.Add(guest); + public void Update(Guest guest) => context.Guests.Update(guest); + public void Remove(Guest guest) => context.Guests.Remove(guest); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/ConfigureProcessInboxJob.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/ConfigureProcessInboxJob.cs new file mode 100644 index 0000000..2106b57 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/ConfigureProcessInboxJob.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Options; +using Quartz; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Inbox; + +internal sealed class ConfigureProcessInboxJob(IOptions inboxOptions) + : IConfigureOptions +{ + private readonly InboxOptions _inboxOptions = inboxOptions.Value; + + public void Configure(QuartzOptions options) + { + string jobName = typeof(ProcessInboxJob).FullName!; + + options + .AddJob(configure => configure.WithIdentity(jobName)) + .AddTrigger(configure => + configure + .ForJob(jobName) + .WithSimpleSchedule(schedule => + schedule.WithIntervalInSeconds(_inboxOptions.IntervalInSeconds).RepeatForever())); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs new file mode 100644 index 0000000..76525c4 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs @@ -0,0 +1,62 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Infrastructure.Inbox; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Inbox; + +internal sealed class IdempotentIntegrationEventHandler( + IIntegrationEventHandler decorated, + IDbConnectionFactory dbConnectionFactory) + : IntegrationEventHandler + where TIntegrationEvent : IIntegrationEvent +{ + public override async Task Handle( + TIntegrationEvent integrationEvent, + CancellationToken cancellationToken = default) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); + + var inboxMessageConsumer = new InboxMessageConsumer(integrationEvent.Id, decorated.GetType().Name); + + if (await InboxConsumerExistsAsync(connection, inboxMessageConsumer)) + { + return; + } + + await decorated.Handle(integrationEvent, cancellationToken); + + await InsertInboxConsumerAsync(connection, inboxMessageConsumer); + } + + private static async Task InboxConsumerExistsAsync( + DbConnection dbConnection, + InboxMessageConsumer inboxMessageConsumer) + { + const string sql = + """ + SELECT EXISTS( + SELECT 1 + FROM rsvp.inbox_message_consumers + WHERE inbox_message_id = @InboxMessageId AND + name = @Name + ) + """; + + return await dbConnection.ExecuteScalarAsync(sql, inboxMessageConsumer); + } + + private static async Task InsertInboxConsumerAsync( + DbConnection dbConnection, + InboxMessageConsumer inboxMessageConsumer) + { + const string sql = + """ + INSERT INTO rsvp.inbox_message_consumers(inbox_message_id, name) + VALUES (@InboxMessageId, @Name) + """; + + await dbConnection.ExecuteAsync(sql, inboxMessageConsumer); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/InboxOptions.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/InboxOptions.cs new file mode 100644 index 0000000..aad6b22 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/InboxOptions.cs @@ -0,0 +1,8 @@ +namespace Eternelle.Modules.Rsvp.Infrastructure.Inbox; + +internal sealed class InboxOptions +{ + public int IntervalInSeconds { get; init; } + + public int BatchSize { get; init; } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/IntegrationEventConsumer.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/IntegrationEventConsumer.cs new file mode 100644 index 0000000..a3c152c --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/IntegrationEventConsumer.cs @@ -0,0 +1,39 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Infrastructure.Inbox; +using Eternelle.Common.Infrastructure.Serialization; +using MassTransit; +using Newtonsoft.Json; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Inbox; + +internal sealed class IntegrationEventConsumer(IDbConnectionFactory dbConnectionFactory) + : IConsumer + where TIntegrationEvent : IntegrationEvent +{ + public async Task Consume(ConsumeContext context) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + + TIntegrationEvent integrationEvent = context.Message; + + var inboxMessage = new InboxMessage + { + Id = integrationEvent.Id, + Type = integrationEvent.GetType().Name, + Content = JsonConvert.SerializeObject(integrationEvent, SerializerSettings.Instance), + OccurredOnUtc = integrationEvent.OccurredOnUtc + }; + + const string sql = + """ + INSERT INTO rsvp.inbox_messages(id, type, content, occurred_on_utc) + VALUES (@Id, @Type, @Content::json, @OccurredOnUtc) + ON CONFLICT (id) DO NOTHING + """; + + await connection.ExecuteAsync(sql, inboxMessage); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/ProcessInboxJob.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/ProcessInboxJob.cs new file mode 100644 index 0000000..223b557 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Inbox/ProcessInboxJob.cs @@ -0,0 +1,143 @@ +using System.Data; +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Infrastructure.Inbox; +using Eternelle.Common.Infrastructure.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Quartz; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Inbox; + +[DisallowConcurrentExecution] +internal sealed partial class ProcessInboxJob( + IDbConnectionFactory dbConnectionFactory, + IServiceScopeFactory serviceScopeFactory, + IDateTimeProvider dateTimeProvider, + IOptions inboxOptions, + ILogger logger) : IJob +{ + private const string ModuleName = "Rsvp"; + + public async Task Execute(IJobExecutionContext context) + { + LogBeginProcessing(logger, ModuleName); + + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbTransaction transaction = await connection.BeginTransactionAsync(); + + IReadOnlyList inboxMessages = await GetInboxMessagesAsync(connection, transaction); + + foreach (InboxMessageResponse inboxMessage in inboxMessages) + { + Exception? exception = null; + + try + { + IIntegrationEvent integrationEvent = JsonConvert.DeserializeObject( + inboxMessage.Content, + SerializerSettings.Instance)!; + + using IServiceScope scope = serviceScopeFactory.CreateScope(); + + IEnumerable handlers = IntegrationEventHandlersFactory.GetHandlers( + integrationEvent.GetType(), + scope.ServiceProvider, + Presentation.AssemblyReference.Assembly); + + foreach (IIntegrationEventHandler integrationEventHandler in handlers) + { + await integrationEventHandler.Handle(integrationEvent, context.CancellationToken); + } + } + catch (Exception caughtException) + { + LogProcessingError(logger, caughtException, ModuleName, inboxMessage.Id); + + exception = caughtException; + } + + await UpdateInboxMessageAsync(connection, transaction, inboxMessage, exception); + } + + await transaction.CommitAsync(); + + LogCompleted(logger, ModuleName); + } + + private async Task> GetInboxMessagesAsync( + IDbConnection connection, + IDbTransaction transaction) + { + string sql = + $""" + SELECT + id AS {nameof(InboxMessageResponse.Id)}, + content AS {nameof(InboxMessageResponse.Content)} + FROM rsvp.inbox_messages + WHERE processed_on_utc IS NULL + ORDER BY occurred_on_utc + LIMIT {inboxOptions.Value.BatchSize} + FOR UPDATE SKIP LOCKED + """; + + IEnumerable inboxMessages = await connection.QueryAsync( + sql, + transaction: transaction); + + return inboxMessages.AsList(); + } + + private async Task UpdateInboxMessageAsync( + IDbConnection connection, + IDbTransaction transaction, + InboxMessageResponse inboxMessage, + Exception? exception) + { + if (exception is null) + { + const string sql = + """ + UPDATE rsvp.inbox_messages + SET processed_on_utc = @ProcessedOnUtc, + error = NULL + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { inboxMessage.Id, ProcessedOnUtc = dateTimeProvider.UtcNow }, + transaction: transaction); + } + else + { + const string sql = + """ + UPDATE rsvp.inbox_messages + SET error = @Error + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { inboxMessage.Id, Error = exception.ToString() }, + transaction: transaction); + } + } + + internal sealed record InboxMessageResponse(Guid Id, string Content); + + [LoggerMessage(LogLevel.Information, "{module} - Beginning to process inbox messages")] + private static partial void LogBeginProcessing(ILogger logger, string module); + + [LoggerMessage(LogLevel.Information, "{module} - Completed processing inbox messages")] + private static partial void LogCompleted(ILogger logger, string module); + + [LoggerMessage(LogLevel.Error, "{module} - Exception while processing inbox message {messageId}")] + private static partial void LogProcessingError(ILogger logger, Exception exception, string module, Guid messageId); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Migrations/20260529094037_InitialRsvp.Designer.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Migrations/20260529094037_InitialRsvp.Designer.cs new file mode 100644 index 0000000..3121c2e --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Migrations/20260529094037_InitialRsvp.Designer.cs @@ -0,0 +1,381 @@ +// +using System; +using Eternelle.Modules.Rsvp.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Eternelle.Modules.Rsvp.Infrastructure.Migrations +{ + [DbContext(typeof(RsvpDbContext))] + [Migration("20260529094037_InitialRsvp")] + partial class InitialRsvp + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("rsvp") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Inbox.InboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("jsonb") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_inbox_messages"); + + b.ToTable("inbox_messages", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Inbox.InboxMessageConsumer", b => + { + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.HasKey("InboxMessageId", "Name") + .HasName("pk_inbox_message_consumers"); + + b.ToTable("inbox_message_consumers", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("jsonb") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.ToTable("outbox_messages", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Outbox.OutboxMessageConsumer", b => + { + b.Property("OutboxMessageId") + .HasColumnType("uuid") + .HasColumnName("outbox_message_id"); + + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.HasKey("OutboxMessageId", "Name") + .HasName("pk_outbox_message_consumers"); + + b.ToTable("outbox_message_consumers", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Modules.Rsvp.Domain.Guests.Guest", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowedCompanions") + .HasColumnType("integer") + .HasColumnName("allowed_companions"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("GroupName") + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("group_name"); + + b.Property("IsLocked") + .HasColumnType("boolean") + .HasColumnName("is_locked"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("LockedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_at_utc"); + + b.Property("LookupCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("lookup_code"); + + b.Property("MobileNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("mobile_number"); + + b.Property("TableNumber") + .HasColumnType("integer") + .HasColumnName("table_number"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WeddingId") + .HasColumnType("uuid") + .HasColumnName("wedding_id"); + + b.HasKey("Id") + .HasName("pk_guests"); + + b.HasIndex("LookupCode") + .IsUnique() + .HasDatabaseName("ix_guests_lookup_code"); + + b.HasIndex("WeddingId") + .HasDatabaseName("ix_guests_wedding_id"); + + b.ToTable("guests", "rsvp", t => + { + t.HasCheckConstraint("ck_guests_allowed_companions", "allowed_companions >= 0"); + }); + }); + + modelBuilder.Entity("Eternelle.Modules.Rsvp.Domain.Submissions.RsvpSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attending") + .HasColumnType("boolean") + .HasColumnName("attending"); + + b.Property("ConfirmationId") + .HasColumnType("uuid") + .HasColumnName("confirmation_id"); + + b.Property("DietaryNote") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("dietary_note"); + + b.Property("EmailSnapshot") + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email_snapshot"); + + b.Property("GuestId") + .HasColumnType("uuid") + .HasColumnName("guest_id"); + + b.Property("InternalNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("internal_note"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("MobileSnapshot") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("mobile_snapshot"); + + b.Property("NameSnapshot") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("name_snapshot"); + + b.Property("Source") + .HasColumnType("integer") + .HasColumnName("source"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("WeddingId") + .HasColumnType("uuid") + .HasColumnName("wedding_id"); + + b.HasKey("Id") + .HasName("pk_submissions"); + + b.HasIndex("ConfirmationId") + .IsUnique() + .HasDatabaseName("ix_submissions_confirmation_id"); + + b.HasIndex("GuestId", "SubmittedAtUtc") + .HasDatabaseName("ix_submissions_guest_id_submitted_at_utc"); + + b.HasIndex("WeddingId", "SubmittedAtUtc") + .HasDatabaseName("ix_submissions_wedding_id_submitted_at_utc"); + + b.ToTable("submissions", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Modules.Rsvp.Domain.Weddings.Wedding", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsClosed") + .HasColumnType("boolean") + .HasColumnName("is_closed"); + + b.Property("RsvpDeadline") + .HasColumnType("date") + .HasColumnName("rsvp_deadline"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WeddingDate") + .HasColumnType("date") + .HasColumnName("wedding_date"); + + b.HasKey("Id") + .HasName("pk_weddings"); + + b.ToTable("weddings", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Modules.Rsvp.Domain.Submissions.RsvpSubmission", b => + { + b.OwnsMany("Eternelle.Modules.Rsvp.Domain.Submissions.SubmissionCompanion", "Companions", b1 => + { + b1.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Attending") + .HasColumnType("boolean") + .HasColumnName("attending"); + + b1.Property("DietaryNote") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("dietary_note"); + + b1.Property("DisplayOrder") + .HasColumnType("integer") + .HasColumnName("display_order"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("name"); + + b1.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b1.HasKey("Id") + .HasName("pk_submission_companions"); + + b1.HasIndex("SubmissionId", "DisplayOrder") + .HasDatabaseName("ix_submission_companions_submission_id_display_order"); + + b1.ToTable("submission_companions", "rsvp"); + + b1.WithOwner() + .HasForeignKey("SubmissionId") + .HasConstraintName("fk_submission_companions_submissions_submission_id"); + }); + + b.Navigation("Companions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Migrations/20260529094037_InitialRsvp.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Migrations/20260529094037_InitialRsvp.cs new file mode 100644 index 0000000..24305d7 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Migrations/20260529094037_InitialRsvp.cs @@ -0,0 +1,247 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Eternelle.Modules.Rsvp.Infrastructure.Migrations; + +/// +public partial class InitialRsvp : Migration +{ + private static readonly string[] _submissionIdDisplayOrder = ["submission_id", "display_order"]; + private static readonly string[] _guestIdSubmittedAt = ["guest_id", "submitted_at"]; + private static readonly string[] _weddingIdSubmittedAt = ["wedding_id", "submitted_at"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "rsvp"); + + migrationBuilder.CreateTable( + name: "guests", + schema: "rsvp", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + wedding_id = table.Column(type: "uuid", nullable: false), + first_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + last_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + email = table.Column(type: "character varying(254)", maxLength: 254, nullable: true), + mobile_number = table.Column(type: "character varying(30)", maxLength: 30, nullable: true), + lookup_code = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + allowed_companions = table.Column(type: "integer", nullable: false), + table_number = table.Column(type: "integer", nullable: true), + group_name = table.Column(type: "character varying(150)", maxLength: 150, nullable: true), + is_locked = table.Column(type: "boolean", nullable: false), + locked_at_utc = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_guests", x => x.id); + table.CheckConstraint("ck_guests_allowed_companions", "allowed_companions >= 0"); + }); + + migrationBuilder.CreateTable( + name: "inbox_message_consumers", + schema: "rsvp", + columns: table => new + { + inbox_message_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_inbox_message_consumers", x => new { x.inbox_message_id, x.name }); + }); + + migrationBuilder.CreateTable( + name: "inbox_messages", + schema: "rsvp", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "text", nullable: false), + content = table.Column(type: "jsonb", maxLength: 2000, nullable: false), + occurred_on_utc = table.Column(type: "timestamp with time zone", nullable: false), + processed_on_utc = table.Column(type: "timestamp with time zone", nullable: true), + error = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_inbox_messages", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "outbox_message_consumers", + schema: "rsvp", + columns: table => new + { + outbox_message_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(500)", maxLength: 500, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_outbox_message_consumers", x => new { x.outbox_message_id, x.name }); + }); + + migrationBuilder.CreateTable( + name: "outbox_messages", + schema: "rsvp", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "text", nullable: false), + content = table.Column(type: "jsonb", maxLength: 2000, nullable: false), + occurred_on_utc = table.Column(type: "timestamp with time zone", nullable: false), + processed_on_utc = table.Column(type: "timestamp with time zone", nullable: true), + error = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_outbox_messages", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "submissions", + schema: "rsvp", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + wedding_id = table.Column(type: "uuid", nullable: false), + guest_id = table.Column(type: "uuid", nullable: true), + name_snapshot = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + email_snapshot = table.Column(type: "character varying(254)", maxLength: 254, nullable: true), + mobile_snapshot = table.Column(type: "character varying(30)", maxLength: 30, nullable: true), + attending = table.Column(type: "boolean", nullable: false), + dietary_note = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + message = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + confirmation_id = table.Column(type: "uuid", nullable: false), + ip_address = table.Column(type: "character varying(45)", maxLength: 45, nullable: true), + source = table.Column(type: "integer", nullable: false), + internal_note = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + submitted_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_submissions", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "weddings", + schema: "rsvp", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + wedding_date = table.Column(type: "date", nullable: false), + rsvp_deadline = table.Column(type: "date", nullable: true), + is_closed = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_weddings", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "submission_companions", + schema: "rsvp", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + submission_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + attending = table.Column(type: "boolean", nullable: false), + dietary_note = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + display_order = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_submission_companions", x => x.id); + table.ForeignKey( + name: "fk_submission_companions_submissions_submission_id", + column: x => x.submission_id, + principalSchema: "rsvp", + principalTable: "submissions", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_guests_lookup_code", + schema: "rsvp", + table: "guests", + column: "lookup_code", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_guests_wedding_id", + schema: "rsvp", + table: "guests", + column: "wedding_id"); + + migrationBuilder.CreateIndex( + name: "ix_submission_companions_submission_id_display_order", + schema: "rsvp", + table: "submission_companions", + columns: _submissionIdDisplayOrder); + + migrationBuilder.CreateIndex( + name: "ix_submissions_confirmation_id", + schema: "rsvp", + table: "submissions", + column: "confirmation_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_submissions_guest_id_submitted_at_utc", + schema: "rsvp", + table: "submissions", + columns: _guestIdSubmittedAt); + + migrationBuilder.CreateIndex( + name: "ix_submissions_wedding_id_submitted_at_utc", + schema: "rsvp", + table: "submissions", + columns: _weddingIdSubmittedAt); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "guests", + schema: "rsvp"); + + migrationBuilder.DropTable( + name: "inbox_message_consumers", + schema: "rsvp"); + + migrationBuilder.DropTable( + name: "inbox_messages", + schema: "rsvp"); + + migrationBuilder.DropTable( + name: "outbox_message_consumers", + schema: "rsvp"); + + migrationBuilder.DropTable( + name: "outbox_messages", + schema: "rsvp"); + + migrationBuilder.DropTable( + name: "submission_companions", + schema: "rsvp"); + + migrationBuilder.DropTable( + name: "weddings", + schema: "rsvp"); + + migrationBuilder.DropTable( + name: "submissions", + schema: "rsvp"); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Migrations/RsvpDbContextModelSnapshot.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Migrations/RsvpDbContextModelSnapshot.cs new file mode 100644 index 0000000..356dab9 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Migrations/RsvpDbContextModelSnapshot.cs @@ -0,0 +1,378 @@ +// +using System; +using Eternelle.Modules.Rsvp.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Eternelle.Modules.Rsvp.Infrastructure.Migrations +{ + [DbContext(typeof(RsvpDbContext))] + partial class RsvpDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("rsvp") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Inbox.InboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("jsonb") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_inbox_messages"); + + b.ToTable("inbox_messages", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Inbox.InboxMessageConsumer", b => + { + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.HasKey("InboxMessageId", "Name") + .HasName("pk_inbox_message_consumers"); + + b.ToTable("inbox_message_consumers", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("jsonb") + .HasColumnName("content"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("OccurredOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_on_utc"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_on_utc"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.ToTable("outbox_messages", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Common.Infrastructure.Outbox.OutboxMessageConsumer", b => + { + b.Property("OutboxMessageId") + .HasColumnType("uuid") + .HasColumnName("outbox_message_id"); + + b.Property("Name") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.HasKey("OutboxMessageId", "Name") + .HasName("pk_outbox_message_consumers"); + + b.ToTable("outbox_message_consumers", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Modules.Rsvp.Domain.Guests.Guest", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowedCompanions") + .HasColumnType("integer") + .HasColumnName("allowed_companions"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("GroupName") + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("group_name"); + + b.Property("IsLocked") + .HasColumnType("boolean") + .HasColumnName("is_locked"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("LockedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_at_utc"); + + b.Property("LookupCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("lookup_code"); + + b.Property("MobileNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("mobile_number"); + + b.Property("TableNumber") + .HasColumnType("integer") + .HasColumnName("table_number"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WeddingId") + .HasColumnType("uuid") + .HasColumnName("wedding_id"); + + b.HasKey("Id") + .HasName("pk_guests"); + + b.HasIndex("LookupCode") + .IsUnique() + .HasDatabaseName("ix_guests_lookup_code"); + + b.HasIndex("WeddingId") + .HasDatabaseName("ix_guests_wedding_id"); + + b.ToTable("guests", "rsvp", t => + { + t.HasCheckConstraint("ck_guests_allowed_companions", "allowed_companions >= 0"); + }); + }); + + modelBuilder.Entity("Eternelle.Modules.Rsvp.Domain.Submissions.RsvpSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attending") + .HasColumnType("boolean") + .HasColumnName("attending"); + + b.Property("ConfirmationId") + .HasColumnType("uuid") + .HasColumnName("confirmation_id"); + + b.Property("DietaryNote") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("dietary_note"); + + b.Property("EmailSnapshot") + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email_snapshot"); + + b.Property("GuestId") + .HasColumnType("uuid") + .HasColumnName("guest_id"); + + b.Property("InternalNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("internal_note"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("MobileSnapshot") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("mobile_snapshot"); + + b.Property("NameSnapshot") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("name_snapshot"); + + b.Property("Source") + .HasColumnType("integer") + .HasColumnName("source"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + b.Property("WeddingId") + .HasColumnType("uuid") + .HasColumnName("wedding_id"); + + b.HasKey("Id") + .HasName("pk_submissions"); + + b.HasIndex("ConfirmationId") + .IsUnique() + .HasDatabaseName("ix_submissions_confirmation_id"); + + b.HasIndex("GuestId", "SubmittedAtUtc") + .HasDatabaseName("ix_submissions_guest_id_submitted_at_utc"); + + b.HasIndex("WeddingId", "SubmittedAtUtc") + .HasDatabaseName("ix_submissions_wedding_id_submitted_at_utc"); + + b.ToTable("submissions", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Modules.Rsvp.Domain.Weddings.Wedding", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsClosed") + .HasColumnType("boolean") + .HasColumnName("is_closed"); + + b.Property("RsvpDeadline") + .HasColumnType("date") + .HasColumnName("rsvp_deadline"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WeddingDate") + .HasColumnType("date") + .HasColumnName("wedding_date"); + + b.HasKey("Id") + .HasName("pk_weddings"); + + b.ToTable("weddings", "rsvp"); + }); + + modelBuilder.Entity("Eternelle.Modules.Rsvp.Domain.Submissions.RsvpSubmission", b => + { + b.OwnsMany("Eternelle.Modules.Rsvp.Domain.Submissions.SubmissionCompanion", "Companions", b1 => + { + b1.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Attending") + .HasColumnType("boolean") + .HasColumnName("attending"); + + b1.Property("DietaryNote") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("dietary_note"); + + b1.Property("DisplayOrder") + .HasColumnType("integer") + .HasColumnName("display_order"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("name"); + + b1.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b1.HasKey("Id") + .HasName("pk_submission_companions"); + + b1.HasIndex("SubmissionId", "DisplayOrder") + .HasDatabaseName("ix_submission_companions_submission_id_display_order"); + + b1.ToTable("submission_companions", "rsvp"); + + b1.WithOwner() + .HasForeignKey("SubmissionId") + .HasConstraintName("fk_submission_companions_submissions_submission_id"); + }); + + b.Navigation("Companions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/ConfigureProcessOutboxJob.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/ConfigureProcessOutboxJob.cs new file mode 100644 index 0000000..96e6936 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/ConfigureProcessOutboxJob.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Options; +using Quartz; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Outbox; + +internal sealed class ConfigureProcessOutboxJob(IOptions outboxOptions) + : IConfigureOptions +{ + private readonly OutboxOptions _outboxOptions = outboxOptions.Value; + + public void Configure(QuartzOptions options) + { + string jobName = typeof(ProcessOutboxJob).FullName!; + + options + .AddJob(configure => configure.WithIdentity(jobName)) + .AddTrigger(configure => + configure + .ForJob(jobName) + .WithSimpleSchedule(schedule => + schedule.WithIntervalInSeconds(_outboxOptions.IntervalInSeconds).RepeatForever())); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/IdempotentDomainEventHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/IdempotentDomainEventHandler.cs new file mode 100644 index 0000000..c2be668 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/IdempotentDomainEventHandler.cs @@ -0,0 +1,61 @@ +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Common.Infrastructure.Outbox; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Outbox; + +internal sealed class IdempotentDomainEventHandler( + IDomainEventHandler decorated, + IDbConnectionFactory dbConnectionFactory) + : DomainEventHandler + where TDomainEvent : IDomainEvent +{ + public override async Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); + + var outboxMessageConsumer = new OutboxMessageConsumer(domainEvent.Id, decorated.GetType().Name); + + if (await OutboxConsumerExistsAsync(connection, outboxMessageConsumer)) + { + return; + } + + await decorated.Handle(domainEvent, cancellationToken); + + await InsertOutboxConsumerAsync(connection, outboxMessageConsumer); + } + + private static async Task OutboxConsumerExistsAsync( + DbConnection dbConnection, + OutboxMessageConsumer outboxMessageConsumer) + { + const string sql = + """ + SELECT EXISTS( + SELECT 1 + FROM rsvp.outbox_message_consumers + WHERE outbox_message_id = @OutboxMessageId AND + name = @Name + ) + """; + + return await dbConnection.ExecuteScalarAsync(sql, outboxMessageConsumer); + } + + private static async Task InsertOutboxConsumerAsync( + DbConnection dbConnection, + OutboxMessageConsumer outboxMessageConsumer) + { + const string sql = + """ + INSERT INTO rsvp.outbox_message_consumers(outbox_message_id, name) + VALUES (@OutboxMessageId, @Name) + """; + + await dbConnection.ExecuteAsync(sql, outboxMessageConsumer); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/OutboxOptions.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/OutboxOptions.cs new file mode 100644 index 0000000..2ef4442 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/OutboxOptions.cs @@ -0,0 +1,8 @@ +namespace Eternelle.Modules.Rsvp.Infrastructure.Outbox; + +internal sealed class OutboxOptions +{ + public int IntervalInSeconds { get; init; } + + public int BatchSize { get; init; } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/ProcessOutboxJob.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/ProcessOutboxJob.cs new file mode 100644 index 0000000..408450a --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Outbox/ProcessOutboxJob.cs @@ -0,0 +1,148 @@ +using System.Data; +using System.Data.Common; +using Dapper; +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.Data; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Domain; +using Eternelle.Common.Infrastructure.Outbox; +using Eternelle.Common.Infrastructure.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Quartz; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Outbox; + +[DisallowConcurrentExecution] +internal sealed partial class ProcessOutboxJob( + IDbConnectionFactory dbConnectionFactory, + IServiceScopeFactory serviceScopeFactory, + IDateTimeProvider dateTimeProvider, + IOptions outboxOptions, + ILogger logger) : IJob +{ + private const string ModuleName = "Rsvp"; + + public async Task Execute(IJobExecutionContext context) + { + LogBeginProcessing(logger, ModuleName); + + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbTransaction transaction = await connection.BeginTransactionAsync(); + + IReadOnlyList outboxMessages = + await GetOutboxMessagesAsync(connection, transaction, context.CancellationToken); + + foreach (OutboxMessageResponse outboxMessage in outboxMessages) + { + Exception? exception = null; + + try + { + IDomainEvent domainEvent = JsonConvert.DeserializeObject( + outboxMessage.Content, + SerializerSettings.Instance)!; + + using IServiceScope scope = serviceScopeFactory.CreateScope(); + + IEnumerable handlers = DomainEventHandlersFactory.GetHandlers( + domainEvent.GetType(), + scope.ServiceProvider, + Application.AssemblyReference.Assembly); + + foreach (IDomainEventHandler domainEventHandler in handlers) + { + await domainEventHandler.Handle(domainEvent, context.CancellationToken); + } + } + catch (Exception caughtException) + { + LogProcessingError(logger, caughtException, ModuleName, outboxMessage.Id); + + exception = caughtException; + } + + await UpdateOutboxMessageAsync(connection, transaction, outboxMessage, exception, context.CancellationToken); + } + + await transaction.CommitAsync(); + + LogCompleted(logger, ModuleName); + } + + private async Task> GetOutboxMessagesAsync( + IDbConnection connection, + IDbTransaction transaction, + CancellationToken cancellationToken) + { + string sql = + $""" + SELECT + id AS {nameof(OutboxMessageResponse.Id)}, + content AS {nameof(OutboxMessageResponse.Content)} + FROM rsvp.outbox_messages + WHERE processed_on_utc IS NULL + ORDER BY occurred_on_utc + LIMIT {outboxOptions.Value.BatchSize} + FOR UPDATE SKIP LOCKED + """; + + IEnumerable outboxMessages = await connection.QueryAsync( + new CommandDefinition(sql, transaction: transaction, cancellationToken: cancellationToken)); + + return outboxMessages.ToList(); + } + + private async Task UpdateOutboxMessageAsync( + IDbConnection connection, + IDbTransaction transaction, + OutboxMessageResponse outboxMessage, + Exception? exception, + CancellationToken cancellationToken) + { + if (exception is null) + { + const string sql = + """ + UPDATE rsvp.outbox_messages + SET processed_on_utc = @ProcessedOnUtc, + error = NULL + WHERE id = @Id + """; + + await connection.ExecuteAsync( + new CommandDefinition(sql, + new { outboxMessage.Id, ProcessedOnUtc = dateTimeProvider.UtcNow }, + transaction: transaction, + cancellationToken: cancellationToken)); + } + else + { + const string sql = + """ + UPDATE rsvp.outbox_messages + SET error = @Error + WHERE id = @Id + """; + + await connection.ExecuteAsync( + new CommandDefinition(sql, + new { outboxMessage.Id, Error = exception.ToString() }, + transaction: transaction, + cancellationToken: cancellationToken)); + } + } + + internal sealed record OutboxMessageResponse(Guid Id, string Content); + + [LoggerMessage(LogLevel.Information, "{module} - Beginning to process outbox messages")] + private static partial void LogBeginProcessing(ILogger logger, string module); + + [LoggerMessage(LogLevel.Information, "{module} - Completed processing outbox messages")] + private static partial void LogCompleted(ILogger logger, string module); + + [LoggerMessage(LogLevel.Error, "{module} - Exception while processing outbox message {messageId}")] + private static partial void LogProcessingError(ILogger logger, Exception exception, string module, Guid messageId); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/RsvpModule.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/RsvpModule.cs new file mode 100644 index 0000000..65ced50 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/RsvpModule.cs @@ -0,0 +1,106 @@ +using Eternelle.Common.Application.EventBus; +using Eternelle.Common.Application.Messaging; +using Eternelle.Common.Infrastructure.Outbox; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Modules.Rsvp.Application.Abstractions.Data; +using Eternelle.Modules.Rsvp.Application.Abstractions.Security; +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Submissions; +using Eternelle.Modules.Rsvp.Domain.Weddings; +using Eternelle.Modules.Rsvp.Infrastructure.Database; +using Eternelle.Modules.Rsvp.Infrastructure.Guests; +using Eternelle.Modules.Rsvp.Infrastructure.Inbox; +using Eternelle.Modules.Rsvp.Infrastructure.Outbox; +using Eternelle.Modules.Rsvp.Infrastructure.Security; +using Eternelle.Modules.Rsvp.Infrastructure.Submissions; +using Eternelle.Modules.Rsvp.Infrastructure.Weddings; +using Eternelle.Modules.Weddings.IntegrationEvents.Weddings; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Eternelle.Modules.Rsvp.Infrastructure; + +public static class RsvpModule +{ + public static IServiceCollection AddRsvpModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddDomainEventHandlers(); + services.AddIntegrationEventHandlers(); + services.AddInfrastructure(configuration); + services.AddEndpoints(Presentation.AssemblyReference.Assembly); + return services; + } + + public static void ConfigureConsumers(MassTransit.IRegistrationConfigurator configurator) + { + configurator.AddConsumer>(); + } + + private static void AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext((sp, options) => + options + .UseNpgsql( + configuration.GetConnectionString("Database"), + npgsql => npgsql.MigrationsHistoryTable(HistoryRepository.DefaultTableName, Schemas.Rsvp)) + .UseSnakeCaseNamingConvention() + .AddInterceptors(sp.GetRequiredService())); + + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); + + services.AddOptions() + .Bind(configuration.GetSection("Rsvp:Outbox")) + .Validate(o => o.IntervalInSeconds > 0 && o.BatchSize > 0, + "Rsvp:Outbox: IntervalInSeconds and BatchSize must be greater than 0.") + .ValidateOnStart(); + services.ConfigureOptions(); + + services.AddOptions() + .Bind(configuration.GetSection("Rsvp:Inbox")) + .Validate(o => o.IntervalInSeconds > 0 && o.BatchSize > 0, + "Rsvp:Inbox: IntervalInSeconds and BatchSize must be greater than 0.") + .ValidateOnStart(); + services.ConfigureOptions(); + } + + private static void AddDomainEventHandlers(this IServiceCollection services) + { + Type[] handlers = Application.AssemblyReference.Assembly.GetTypes() + .Where(t => t.IsAssignableTo(typeof(IDomainEventHandler))).ToArray(); + + foreach (Type handler in handlers) + { + services.TryAddScoped(handler); + Type domainEvent = handler.GetInterfaces() + .Single(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDomainEventHandler<>)) + .GetGenericArguments().Single(); + Type closed = typeof(IdempotentDomainEventHandler<>).MakeGenericType(domainEvent); + services.Decorate(handler, closed); + } + } + + private static void AddIntegrationEventHandlers(this IServiceCollection services) + { + Type[] handlers = Presentation.AssemblyReference.Assembly.GetTypes() + .Where(t => t.IsAssignableTo(typeof(IIntegrationEventHandler))).ToArray(); + + foreach (Type handler in handlers) + { + services.TryAddScoped(handler); + Type integrationEvent = handler.GetInterfaces() + .Single(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IIntegrationEventHandler<>)) + .GetGenericArguments().Single(); + Type closed = typeof(IdempotentIntegrationEventHandler<>).MakeGenericType(integrationEvent); + services.Decorate(handler, closed); + } + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Security/LookupCodeGenerator.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Security/LookupCodeGenerator.cs new file mode 100644 index 0000000..1cfbd52 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Security/LookupCodeGenerator.cs @@ -0,0 +1,24 @@ +using System.Security.Cryptography; +using Eternelle.Modules.Rsvp.Application.Abstractions.Security; +using Eternelle.Modules.Rsvp.Domain.Shared; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Security; + +internal sealed class LookupCodeGenerator : ITokenGenerator +{ + public string GenerateLookupCode() + { + const int length = LookupCode.MaxLength; + Span buffer = stackalloc char[length]; + Span entropy = stackalloc byte[length]; + RandomNumberGenerator.Fill(entropy); + + ReadOnlySpan alphabet = LookupCode.Alphabet; + for (int i = 0; i < length; i++) + { + buffer[i] = alphabet[entropy[i] % alphabet.Length]; + } + + return new string(buffer); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Submissions/RsvpSubmissionConfiguration.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Submissions/RsvpSubmissionConfiguration.cs new file mode 100644 index 0000000..c661433 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Submissions/RsvpSubmissionConfiguration.cs @@ -0,0 +1,101 @@ +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; +using Eternelle.Modules.Rsvp.Domain.Shared; +using Eternelle.Modules.Rsvp.Domain.Submissions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Submissions; + +internal sealed class RsvpSubmissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder b) + { + b.ToTable("submissions"); + b.HasKey(s => s.Id); + + b.Property(s => s.WeddingId).IsRequired(); + b.HasIndex(s => new { s.WeddingId, s.SubmittedAtUtc }); + + b.Property(s => s.GuestId); + b.HasIndex(s => new { s.GuestId, s.SubmittedAtUtc }); + + b.Property(s => s.NameSnapshot) + .HasColumnName("name_snapshot") + .HasConversion(v => v.Value, v => PersonName.FromPersistence(v)) + .IsRequired().HasMaxLength(PersonName.MaxLength); + + b.Property(s => s.EmailSnapshot) + .HasColumnName("email_snapshot") + .HasConversion( + v => v != null ? v.Value : null, + v => v != null ? EmailAddress.FromPersistence(v) : null) + .HasMaxLength(EmailAddress.MaxLength); + + b.Property(s => s.MobileSnapshot) + .HasColumnName("mobile_snapshot") + .HasConversion( + v => v != null ? v.Value : null, + v => v != null ? PhoneNumber.FromPersistence(v) : null) + .HasMaxLength(PhoneNumber.MaxLength); + + b.Property(s => s.Attending).IsRequired(); + + b.Property(s => s.DietaryNote) + .HasConversion( + v => v != null ? v.Value : null, + v => v != null ? DietaryNote.FromPersistence(v) : null) + .HasMaxLength(DietaryNote.MaxLength); + + b.Property(s => s.Message) + .HasConversion( + v => v != null ? v.Value : null, + v => v != null ? RichDescription.FromPersistence(v) : null) + .HasMaxLength(RichDescription.MaxLength); + + b.Property(s => s.ConfirmationId).IsRequired(); + b.HasIndex(s => s.ConfirmationId).IsUnique(); + + b.Property(s => s.IpAddress) + .HasConversion( + v => v != null ? v.ToString() : null, + v => v != null ? System.Net.IPAddress.Parse(v) : null) + .HasMaxLength(RsvpSubmission.IpAddressMaxLength); + + b.Property(s => s.Source).HasConversion().IsRequired(); + + b.Property(s => s.InternalNote) + .HasConversion( + v => v != null ? v.Value : null, + v => v != null ? InternalNote.FromPersistence(v) : null) + .HasMaxLength(InternalNote.MaxLength); + + b.Property(s => s.SubmittedAtUtc).HasColumnName("submitted_at").IsRequired(); + + b.OwnsMany(s => s.Companions, c => + { + c.ToTable("submission_companions"); + c.HasKey(x => x.Id); + c.WithOwner().HasForeignKey(x => x.SubmissionId); + + c.Property(x => x.Name) + .HasConversion(v => v.Value, v => PersonName.FromPersistence(v)) + .IsRequired().HasMaxLength(PersonName.MaxLength); + + c.Property(x => x.Attending).IsRequired(); + + c.Property(x => x.DietaryNote) + .HasConversion( + v => v != null ? v.Value : null, + v => v != null ? DietaryNote.FromPersistence(v) : null) + .HasMaxLength(DietaryNote.MaxLength); + + c.Property(x => x.DisplayOrder).IsRequired(); + c.HasIndex(x => new { x.SubmissionId, x.DisplayOrder }); + }); + + b.Navigation(s => s.Companions) + .HasField("_companions") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Submissions/RsvpSubmissionRepository.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Submissions/RsvpSubmissionRepository.cs new file mode 100644 index 0000000..8b9dbf9 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Submissions/RsvpSubmissionRepository.cs @@ -0,0 +1,32 @@ +using Eternelle.Modules.Rsvp.Domain.Guests; +using Eternelle.Modules.Rsvp.Domain.Submissions; +using Eternelle.Modules.Rsvp.Domain.Weddings; +using Eternelle.Modules.Rsvp.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Submissions; + +internal sealed class RsvpSubmissionRepository(RsvpDbContext context) : IRsvpSubmissionRepository +{ + public Task GetByIdAsync(SubmissionId id, CancellationToken cancellationToken = default) => + context.Submissions.Include(s => s.Companions).SingleOrDefaultAsync(s => s.Id == id, cancellationToken); + + public Task GetLatestByGuestAsync(GuestId guestId, CancellationToken cancellationToken = default) => + context.Submissions.Include(s => s.Companions) + .Where(s => s.GuestId == guestId) + .OrderByDescending(s => s.SubmittedAtUtc) + .FirstOrDefaultAsync(cancellationToken); + + public async Task> ListByWeddingAsync( + WeddingId weddingId, CancellationToken cancellationToken = default) => + await context.Submissions.Include(s => s.Companions) + .Where(s => s.WeddingId == weddingId) + .OrderByDescending(s => s.SubmittedAtUtc) + .ToListAsync(cancellationToken); + + public void Insert(RsvpSubmission submission) => context.Submissions.Add(submission); + + public Task NullifyGuestIdAsync(GuestId guestId, CancellationToken cancellationToken = default) => + context.Submissions.Where(s => s.GuestId == guestId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.GuestId, (GuestId?)null), cancellationToken); +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Weddings/WeddingConfiguration.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Weddings/WeddingConfiguration.cs new file mode 100644 index 0000000..be5a684 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Weddings/WeddingConfiguration.cs @@ -0,0 +1,19 @@ +using Eternelle.Modules.Rsvp.Domain.Weddings; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Weddings; + +internal sealed class WeddingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder b) + { + b.ToTable("weddings"); + b.HasKey(w => w.Id); + b.Property(w => w.WeddingDate).IsRequired(); + b.Property(w => w.RsvpDeadline); + b.Property(w => w.IsClosed).IsRequired(); + b.Property(w => w.CreatedAtUtc).HasColumnName("created_at").IsRequired(); + b.Property(w => w.UpdatedAtUtc).HasColumnName("updated_at").IsRequired(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Weddings/WeddingProjection.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Weddings/WeddingProjection.cs new file mode 100644 index 0000000..612e1d4 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Infrastructure/Weddings/WeddingProjection.cs @@ -0,0 +1,31 @@ +using Eternelle.Modules.Rsvp.Domain.Weddings; +using Eternelle.Modules.Rsvp.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Eternelle.Modules.Rsvp.Infrastructure.Weddings; + +internal sealed class WeddingProjection(RsvpDbContext context) : IWeddingProjection +{ + public Task GetAsync(WeddingId id, CancellationToken cancellationToken = default) => + context.Weddings.SingleOrDefaultAsync(w => w.Id == id, cancellationToken); + + public async Task UpsertAsync(Wedding wedding, CancellationToken cancellationToken = default) + { + Wedding? existing = await context.Weddings.SingleOrDefaultAsync(w => w.Id == wedding.Id, cancellationToken); + if (existing is null) + { + context.Weddings.Add(wedding); + } + else + { + context.Entry(existing).CurrentValues.SetValues(wedding); + } + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(Wedding wedding, CancellationToken cancellationToken = default) + { + context.Weddings.Update(wedding); + await context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.IntegrationEvents/Eternelle.Modules.Rsvp.IntegrationEvents.csproj b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.IntegrationEvents/Eternelle.Modules.Rsvp.IntegrationEvents.csproj new file mode 100644 index 0000000..7075f4d --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.IntegrationEvents/Eternelle.Modules.Rsvp.IntegrationEvents.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.IntegrationEvents/Guests/GuestAddedIntegrationEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.IntegrationEvents/Guests/GuestAddedIntegrationEvent.cs new file mode 100644 index 0000000..55102c5 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.IntegrationEvents/Guests/GuestAddedIntegrationEvent.cs @@ -0,0 +1,26 @@ +using Eternelle.Common.Application.EventBus; + +namespace Eternelle.Modules.Rsvp.IntegrationEvents.Guests; + +public sealed class GuestAddedIntegrationEvent : IntegrationEvent +{ + public GuestAddedIntegrationEvent( + Guid id, + DateTime occurredOnUtc, + Guid guestId, + Guid weddingId, + string lookupCode, + int allowedCompanions) + : base(id, occurredOnUtc) + { + GuestId = guestId; + WeddingId = weddingId; + LookupCode = lookupCode; + AllowedCompanions = allowedCompanions; + } + + public Guid GuestId { get; init; } + public Guid WeddingId { get; init; } + public string LookupCode { get; init; } + public int AllowedCompanions { get; init; } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.IntegrationEvents/Submissions/RsvpSubmittedIntegrationEvent.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.IntegrationEvents/Submissions/RsvpSubmittedIntegrationEvent.cs new file mode 100644 index 0000000..d9e0d5a --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.IntegrationEvents/Submissions/RsvpSubmittedIntegrationEvent.cs @@ -0,0 +1,32 @@ +using Eternelle.Common.Application.EventBus; + +namespace Eternelle.Modules.Rsvp.IntegrationEvents.Submissions; + +public sealed class RsvpSubmittedIntegrationEvent : IntegrationEvent +{ + public RsvpSubmittedIntegrationEvent( + Guid id, + DateTime occurredOnUtc, + Guid submissionId, + Guid weddingId, + Guid guestId, + bool attending, + int companionCount, + DateTime submittedAtUtc) + : base(id, occurredOnUtc) + { + SubmissionId = submissionId; + WeddingId = weddingId; + GuestId = guestId; + Attending = attending; + CompanionCount = companionCount; + SubmittedAtUtc = submittedAtUtc; + } + + public Guid SubmissionId { get; init; } + public Guid WeddingId { get; init; } + public Guid GuestId { get; init; } + public bool Attending { get; init; } + public int CompanionCount { get; init; } + public DateTime SubmittedAtUtc { get; init; } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/AssemblyReference.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/AssemblyReference.cs new file mode 100644 index 0000000..01b740b --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/AssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Eternelle.Modules.Rsvp.Presentation; + +public static class AssemblyReference +{ + public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Eternelle.Modules.Rsvp.Presentation.csproj b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Eternelle.Modules.Rsvp.Presentation.csproj new file mode 100644 index 0000000..e19bb0e --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Eternelle.Modules.Rsvp.Presentation.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/AddGuestEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/AddGuestEndpoint.cs new file mode 100644 index 0000000..3c11c64 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/AddGuestEndpoint.cs @@ -0,0 +1,50 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Guests.AddGuest; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Guests; + +internal sealed class AddGuestEndpoint : IEndpoint +{ + internal sealed record Request( + string FirstName, + string LastName, + string? Email, + string? MobileNumber, + int AllowedCompanions, + int? TableNumber, + string? GroupName); + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost("weddings/{weddingId:guid}/guests", async ( + Guid weddingId, + Request request, + ISender sender, + CancellationToken ct) => + { + var command = new AddGuestCommand( + weddingId, + request.FirstName, + request.LastName, + request.Email, + request.MobileNumber, + request.AllowedCompanions, + request.TableNumber, + request.GroupName); + + Result result = await sender.Send(command, ct); + + return result.Match( + id => Results.Created($"/weddings/{weddingId}/guests/{id}", new { id }), + ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/ChangeGuestAllowanceEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/ChangeGuestAllowanceEndpoint.cs new file mode 100644 index 0000000..7ecdede --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/ChangeGuestAllowanceEndpoint.cs @@ -0,0 +1,34 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Guests.ChangeGuestAllowance; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Guests; + +internal sealed class ChangeGuestAllowanceEndpoint : IEndpoint +{ + internal sealed record Request(int AllowedCompanions); + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPut("weddings/{weddingId:guid}/guests/{guestId:guid}/allowance", async ( + Guid weddingId, + Guid guestId, + Request request, + ISender sender, + CancellationToken ct) => + { + var command = new ChangeGuestAllowanceCommand(weddingId, guestId, request.AllowedCompanions); + + Result result = await sender.Send(command, ct); + + return result.Match(Results.NoContent, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/GetGuestEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/GetGuestEndpoint.cs new file mode 100644 index 0000000..a2df9f0 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/GetGuestEndpoint.cs @@ -0,0 +1,29 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Guests.GetGuest; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Guests; + +internal sealed class GetGuestEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("weddings/{weddingId:guid}/guests/{guestId:guid}", async ( + Guid weddingId, + Guid guestId, + ISender sender, + CancellationToken ct) => + { + Result result = await sender.Send(new GetGuestQuery(weddingId, guestId), ct); + + return result.Match(Results.Ok, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/ListGuestsEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/ListGuestsEndpoint.cs new file mode 100644 index 0000000..f7231c3 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/ListGuestsEndpoint.cs @@ -0,0 +1,36 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Guests.ListGuests; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Guests; + +internal sealed class ListGuestsEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("weddings/{weddingId:guid}/guests", Handler) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } + + private static async Task Handler( + Guid weddingId, + ISender sender, + CancellationToken ct, + string? groupName = null, + string? attendingState = null, + bool? isLocked = null, + int skip = 0, + int take = 50) + { + Result> result = await sender.Send( + new ListGuestsQuery(weddingId, groupName, attendingState, isLocked, skip, take), ct); + + return result.Match(Results.Ok, ApiResults.Problem); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/LockGuestEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/LockGuestEndpoint.cs new file mode 100644 index 0000000..92e55f4 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/LockGuestEndpoint.cs @@ -0,0 +1,29 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Guests.LockGuest; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Guests; + +internal sealed class LockGuestEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost("weddings/{weddingId:guid}/guests/{guestId:guid}/lock", async ( + Guid weddingId, + Guid guestId, + ISender sender, + CancellationToken ct) => + { + Result result = await sender.Send(new LockGuestCommand(weddingId, guestId), ct); + + return result.Match(Results.NoContent, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/RegenerateGuestTokenEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/RegenerateGuestTokenEndpoint.cs new file mode 100644 index 0000000..1189ef1 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/RegenerateGuestTokenEndpoint.cs @@ -0,0 +1,31 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Guests.RegenerateGuestToken; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Guests; + +internal sealed class RegenerateGuestTokenEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost("weddings/{weddingId:guid}/guests/{guestId:guid}/regenerate-token", async ( + Guid weddingId, + Guid guestId, + ISender sender, + CancellationToken ct) => + { + Result result = await sender.Send(new RegenerateGuestTokenCommand(weddingId, guestId), ct); + + return result.Match( + lookupCode => Results.Ok(new { lookupCode }), + ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/RemoveGuestEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/RemoveGuestEndpoint.cs new file mode 100644 index 0000000..7170d3a --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/RemoveGuestEndpoint.cs @@ -0,0 +1,29 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Guests.RemoveGuest; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Guests; + +internal sealed class RemoveGuestEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapDelete("weddings/{weddingId:guid}/guests/{guestId:guid}", async ( + Guid weddingId, + Guid guestId, + ISender sender, + CancellationToken ct) => + { + Result result = await sender.Send(new RemoveGuestCommand(weddingId, guestId), ct); + + return result.Match(Results.NoContent, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/UnlockGuestEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/UnlockGuestEndpoint.cs new file mode 100644 index 0000000..9359307 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/UnlockGuestEndpoint.cs @@ -0,0 +1,29 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Guests.UnlockGuest; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Guests; + +internal sealed class UnlockGuestEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost("weddings/{weddingId:guid}/guests/{guestId:guid}/unlock", async ( + Guid weddingId, + Guid guestId, + ISender sender, + CancellationToken ct) => + { + Result result = await sender.Send(new UnlockGuestCommand(weddingId, guestId), ct); + + return result.Match(Results.NoContent, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/UpdateGuestContactEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/UpdateGuestContactEndpoint.cs new file mode 100644 index 0000000..87d9643 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/UpdateGuestContactEndpoint.cs @@ -0,0 +1,44 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Guests.UpdateGuestContact; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Guests; + +internal sealed class UpdateGuestContactEndpoint : IEndpoint +{ + internal sealed record Request( + string FirstName, + string LastName, + string? Email, + string? MobileNumber); + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPut("weddings/{weddingId:guid}/guests/{guestId:guid}", async ( + Guid weddingId, + Guid guestId, + Request request, + ISender sender, + CancellationToken ct) => + { + var command = new UpdateGuestContactCommand( + weddingId, + guestId, + request.FirstName, + request.LastName, + request.Email, + request.MobileNumber); + + Result result = await sender.Send(command, ct); + + return result.Match(Results.NoContent, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/UpdateGuestPlacementEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/UpdateGuestPlacementEndpoint.cs new file mode 100644 index 0000000..4dc9c4d --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Guests/UpdateGuestPlacementEndpoint.cs @@ -0,0 +1,34 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Guests.UpdateGuestPlacement; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Guests; + +internal sealed class UpdateGuestPlacementEndpoint : IEndpoint +{ + internal sealed record Request(int? TableNumber, string? GroupName); + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPut("weddings/{weddingId:guid}/guests/{guestId:guid}/placement", async ( + Guid weddingId, + Guid guestId, + Request request, + ISender sender, + CancellationToken ct) => + { + var command = new UpdateGuestPlacementCommand(weddingId, guestId, request.TableNumber, request.GroupName); + + Result result = await sender.Send(command, ct); + + return result.Match(Results.NoContent, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Public/GetInvitationEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Public/GetInvitationEndpoint.cs new file mode 100644 index 0000000..1ee8a48 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Public/GetInvitationEndpoint.cs @@ -0,0 +1,26 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Public.GetInvitation; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Public; + +internal sealed class GetInvitationEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("rsvp/{lookupCode}", async (string lookupCode, ISender sender, CancellationToken ct) => + { + Result result = await sender.Send(new GetInvitationQuery(lookupCode), ct); + return result.Match(Results.Ok, ApiResults.Problem); + }) + .WithTags(Tags.RsvpPublic) + .AllowAnonymous() + .RequireRateLimiting(RateLimitingPolicies.RsvpLookup); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Public/SubmitRsvpEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Public/SubmitRsvpEndpoint.cs new file mode 100644 index 0000000..d5fd362 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Public/SubmitRsvpEndpoint.cs @@ -0,0 +1,45 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Public.SubmitRsvp; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Public; + +internal sealed class SubmitRsvpEndpoint : IEndpoint +{ + internal sealed record CompanionRequest(string Name, bool Attending, string? DietaryNote); + + internal sealed record Request( + bool Attending, + string? DietaryNote, + string? Message, + IReadOnlyList? Companions); + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost("rsvp/{lookupCode}", async ( + string lookupCode, Request body, HttpContext http, ISender sender, CancellationToken ct) => + { + var companions = (body.Companions ?? []) + .Select(c => new CompanionInput(c.Name, c.Attending, c.DietaryNote)) + .ToList(); + + var cmd = new SubmitRsvpCommand( + lookupCode, body.Attending, body.DietaryNote, body.Message, companions, + http.Connection.RemoteIpAddress?.ToString()); + + Result result = await sender.Send(cmd, ct); + return result.Match( + confirmationId => Results.Ok(new { confirmationId }), + ApiResults.Problem); + }) + .WithTags(Tags.RsvpPublic) + .AllowAnonymous() + .RequireRateLimiting(RateLimitingPolicies.RsvpSubmit); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/RateLimitingPolicies.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/RateLimitingPolicies.cs new file mode 100644 index 0000000..ba8e090 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/RateLimitingPolicies.cs @@ -0,0 +1,10 @@ +namespace Eternelle.Modules.Rsvp.Presentation; + +public static class RateLimitingPolicies +{ + /// 30 requests / 60 s per IP — applied to GET /rsvp/{lookupCode}. + public const string RsvpLookup = "rsvp-lookup"; + + /// 5 requests / 60 s per IP — applied to POST /rsvp/{lookupCode}. + public const string RsvpSubmit = "rsvp-submit"; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/GetHeadcountEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/GetHeadcountEndpoint.cs new file mode 100644 index 0000000..4af8f5b --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/GetHeadcountEndpoint.cs @@ -0,0 +1,28 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Submissions.GetHeadcount; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Submissions; + +internal sealed class GetHeadcountEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("weddings/{weddingId:guid}/headcount", async ( + Guid weddingId, + ISender sender, + CancellationToken ct) => + { + Result result = await sender.Send(new GetHeadcountQuery(weddingId), ct); + + return result.Match(Results.Ok, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/GetSubmissionEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/GetSubmissionEndpoint.cs new file mode 100644 index 0000000..761ee6e --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/GetSubmissionEndpoint.cs @@ -0,0 +1,30 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Submissions.GetSubmission; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Submissions; + +internal sealed class GetSubmissionEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("weddings/{weddingId:guid}/submissions/{submissionId:guid}", async ( + Guid weddingId, + Guid submissionId, + ISender sender, + CancellationToken ct) => + { + Result result = + await sender.Send(new GetSubmissionQuery(weddingId, submissionId), ct); + + return result.Match(Results.Ok, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/ListSubmissionsEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/ListSubmissionsEndpoint.cs new file mode 100644 index 0000000..bf62ddb --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/ListSubmissionsEndpoint.cs @@ -0,0 +1,40 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Submissions.ListSubmissions; +using Eternelle.Modules.Rsvp.Domain.Submissions; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Submissions; + +internal sealed class ListSubmissionsEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("weddings/{weddingId:guid}/submissions", Handler) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } + + private static async Task Handler( + Guid weddingId, + ISender sender, + CancellationToken ct, + bool? attending = null, + string? source = null, + int skip = 0, + int take = 50) + { + RsvpSource? parsedSource = Enum.TryParse(source, true, out RsvpSource s) && Enum.IsDefined(s) + ? s + : null; + + Result> result = await sender.Send( + new ListSubmissionsQuery(weddingId, attending, parsedSource, skip, take), ct); + + return result.Match(Results.Ok, ApiResults.Problem); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/SubmitOnBehalfEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/SubmitOnBehalfEndpoint.cs new file mode 100644 index 0000000..9fc7d90 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Submissions/SubmitOnBehalfEndpoint.cs @@ -0,0 +1,54 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Submissions.SubmitOnBehalf; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Submissions; + +internal sealed class SubmitOnBehalfEndpoint : IEndpoint +{ + internal sealed record CompanionRequest(string Name, bool Attending, string? DietaryNote); + + internal sealed record Request( + bool Attending, + string? DietaryNote, + string? Message, + IReadOnlyList? Companions, + string InternalNote); + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost("weddings/{weddingId:guid}/guests/{guestId:guid}/submissions", async ( + Guid weddingId, + Guid guestId, + Request request, + ISender sender, + CancellationToken ct) => + { + var companions = (request.Companions ?? []) + .Select(c => new CompanionInput(c.Name, c.Attending, c.DietaryNote)) + .ToList(); + + var command = new SubmitOnBehalfCommand( + weddingId, + guestId, + request.Attending, + request.DietaryNote, + request.Message, + companions, + request.InternalNote); + + Result result = await sender.Send(command, ct); + + return result.Match( + confirmationId => Results.Ok(new { confirmationId }), + ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Tags.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Tags.cs new file mode 100644 index 0000000..dd6defe --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Tags.cs @@ -0,0 +1,7 @@ +namespace Eternelle.Modules.Rsvp.Presentation; + +internal static class Tags +{ + public const string Rsvp = "Rsvp"; + public const string RsvpPublic = "Rsvp.Public"; +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Weddings/ClearRsvpDeadlineEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Weddings/ClearRsvpDeadlineEndpoint.cs new file mode 100644 index 0000000..4a6ff01 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Weddings/ClearRsvpDeadlineEndpoint.cs @@ -0,0 +1,28 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Weddings.ClearRsvpDeadline; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Weddings; + +internal sealed class ClearRsvpDeadlineEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapDelete("weddings/{weddingId:guid}/rsvp/deadline", async ( + Guid weddingId, + ISender sender, + CancellationToken ct) => + { + Result result = await sender.Send(new ClearRsvpDeadlineCommand(weddingId), ct); + + return result.Match(Results.NoContent, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Weddings/SetRsvpDeadlineEndpoint.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Weddings/SetRsvpDeadlineEndpoint.cs new file mode 100644 index 0000000..699f186 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Weddings/SetRsvpDeadlineEndpoint.cs @@ -0,0 +1,33 @@ +using Eternelle.Common.Domain; +using Eternelle.Common.Presentation.Endpoints; +using Eternelle.Common.Presentation.Results; +using Eternelle.Modules.Rsvp.Application.Weddings.SetRsvpDeadline; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Eternelle.Modules.Rsvp.Presentation.Weddings; + +internal sealed class SetRsvpDeadlineEndpoint : IEndpoint +{ + internal sealed record Request(DateOnly Deadline); + + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPut("weddings/{weddingId:guid}/rsvp/deadline", async ( + Guid weddingId, + Request request, + ISender sender, + CancellationToken ct) => + { + var command = new SetRsvpDeadlineCommand(weddingId, request.Deadline); + + Result result = await sender.Send(command, ct); + + return result.Match(Results.NoContent, ApiResults.Problem); + }) + .WithTags(Tags.Rsvp) + .RequireAuthorization(); + } +} diff --git a/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Weddings/WeddingCreatedIntegrationEventHandler.cs b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Weddings/WeddingCreatedIntegrationEventHandler.cs new file mode 100644 index 0000000..ef20b69 --- /dev/null +++ b/src/Modules/Rsvp/Eternelle.Modules.Rsvp.Presentation/Weddings/WeddingCreatedIntegrationEventHandler.cs @@ -0,0 +1,24 @@ +using Eternelle.Common.Application.Clock; +using Eternelle.Common.Application.EventBus; +using Eternelle.Modules.Rsvp.Domain.Weddings; +using Eternelle.Modules.Weddings.IntegrationEvents.Weddings; + +namespace Eternelle.Modules.Rsvp.Presentation.Weddings; + +internal sealed class WeddingCreatedIntegrationEventHandler( + IWeddingProjection projection, + IDateTimeProvider dateTimeProvider) + : IntegrationEventHandler +{ + public override async Task Handle( + WeddingCreatedIntegrationEvent integrationEvent, + CancellationToken cancellationToken = default) + { + var wedding = Wedding.Create( + new WeddingId(integrationEvent.WeddingId), + integrationEvent.WeddingDate, + dateTimeProvider.UtcNow); + + await projection.UpsertAsync(wedding, cancellationToken); + } +} diff --git a/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Application/Tenants/GetTenant/GetTenantQueryHandler.cs b/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Application/Tenants/GetTenant/GetTenantQueryHandler.cs index 077dae2..0ee1b63 100644 --- a/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Application/Tenants/GetTenant/GetTenantQueryHandler.cs +++ b/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Application/Tenants/GetTenant/GetTenantQueryHandler.cs @@ -12,7 +12,7 @@ internal sealed class GetTenantQueryHandler(IDbConnectionFactory dbConnectionFac { public async Task> Handle(GetTenantQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); string sql = $""" diff --git a/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Application/Tenants/GetTenantSections/GetTenantSectionsQueryHandler.cs b/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Application/Tenants/GetTenantSections/GetTenantSectionsQueryHandler.cs index b28d1f0..e94fc2a 100644 --- a/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Application/Tenants/GetTenantSections/GetTenantSectionsQueryHandler.cs +++ b/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Application/Tenants/GetTenantSections/GetTenantSectionsQueryHandler.cs @@ -12,7 +12,7 @@ internal sealed class GetTenantSectionsQueryHandler(IDbConnectionFactory dbConne public async Task>> Handle( GetTenantSectionsQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); string sql = $""" diff --git a/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs b/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs index da48388..ac35a44 100644 --- a/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs +++ b/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs @@ -16,7 +16,7 @@ public override async Task Handle( TIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); var inboxMessageConsumer = new InboxMessageConsumer(integrationEvent.Id, decorated.GetType().Name); diff --git a/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Infrastructure/Outbox/IdempotentDomainEventHandler.cs b/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Infrastructure/Outbox/IdempotentDomainEventHandler.cs index c93ff0f..d052098 100644 --- a/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Infrastructure/Outbox/IdempotentDomainEventHandler.cs +++ b/src/Modules/Tenancy/Eternelle.Modules.Tenancy.Infrastructure/Outbox/IdempotentDomainEventHandler.cs @@ -15,7 +15,7 @@ internal sealed class IdempotentDomainEventHandler( { public override async Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken = default) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); var outboxMessageConsumer = new OutboxMessageConsumer(domainEvent.Id, decorated.GetType().Name); diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQueryHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQueryHandler.cs index a8250a8..dc31c25 100644 --- a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQueryHandler.cs +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUser/GetUserQueryHandler.cs @@ -12,7 +12,7 @@ internal sealed class GetUserQueryHandler(IDbConnectionFactory dbConnectionFacto { public async Task> Handle(GetUserQuery request, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQueryHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQueryHandler.cs index c8b94d2..7ad42d2 100644 --- a/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQueryHandler.cs +++ b/src/Modules/Users/Eternelle.Modules.Users.Application/Users/GetUserPermissions/GetUserPermissionsQueryHandler.cs @@ -15,7 +15,7 @@ public async Task> Handle( GetUserPermissionsQuery request, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" @@ -36,7 +36,7 @@ FROM users.users u return Result.Failure(UserErrors.NotFound(request.IdentityId)); } - return new PermissionsResponse(permissions[0].UserId, permissions.Select(p => p.Permission).ToHashSet()); + return new PermissionsResponse(permissions[0].UserId, [.. permissions.Select(p => p.Permission)]); } internal sealed class UserPermission diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs index a76eda5..f9ef3e0 100644 --- a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs @@ -16,7 +16,7 @@ public override async Task Handle( TIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); var inboxMessageConsumer = new InboxMessageConsumer(integrationEvent.Id, decorated.GetType().Name); diff --git a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/IdempotentDomainEventHandler.cs b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/IdempotentDomainEventHandler.cs index 774927e..1fcff44 100644 --- a/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/IdempotentDomainEventHandler.cs +++ b/src/Modules/Users/Eternelle.Modules.Users.Infrastructure/Outbox/IdempotentDomainEventHandler.cs @@ -15,7 +15,7 @@ internal sealed class IdempotentDomainEventHandler( { public override async Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken = default) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); var outboxMessageConsumer = new OutboxMessageConsumer(domainEvent.Id, decorated.GetType().Name); diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/CreateCeremonyAct/CreateCeremonyActCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/CreateCeremonyAct/CreateCeremonyActCommandHandler.cs index d5620a5..678f168 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/CreateCeremonyAct/CreateCeremonyActCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/CreateCeremonyAct/CreateCeremonyActCommandHandler.cs @@ -2,6 +2,7 @@ using Eternelle.Common.Domain; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.CeremonyActs; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/CreateCeremonyAct/CreateCeremonyActCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/CreateCeremonyAct/CreateCeremonyActCommandValidator.cs index d75aab8..a42fc3c 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/CreateCeremonyAct/CreateCeremonyActCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/CreateCeremonyAct/CreateCeremonyActCommandValidator.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/GetCeremonyActs/GetCeremonyActsQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/GetCeremonyActs/GetCeremonyActsQueryHandler.cs index 813fcb6..20a577f 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/GetCeremonyActs/GetCeremonyActsQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/GetCeremonyActs/GetCeremonyActsQueryHandler.cs @@ -13,7 +13,7 @@ public async Task>> Handle( GetCeremonyActsQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/UpdateCeremonyAct/UpdateCeremonyActCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/UpdateCeremonyAct/UpdateCeremonyActCommandHandler.cs index f4a75b0..1f716b4 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/UpdateCeremonyAct/UpdateCeremonyActCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/UpdateCeremonyAct/UpdateCeremonyActCommandHandler.cs @@ -2,6 +2,7 @@ using Eternelle.Common.Domain; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.CeremonyActs; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/UpdateCeremonyAct/UpdateCeremonyActCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/UpdateCeremonyAct/UpdateCeremonyActCommandValidator.cs index 83f9880..dfeacd8 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/UpdateCeremonyAct/UpdateCeremonyActCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/CeremonyActs/UpdateCeremonyAct/UpdateCeremonyActCommandValidator.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/CreateDressCodeConfig/CreateDressCodeConfigCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/CreateDressCodeConfig/CreateDressCodeConfigCommandHandler.cs index b205363..ea7508b 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/CreateDressCodeConfig/CreateDressCodeConfigCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/CreateDressCodeConfig/CreateDressCodeConfigCommandHandler.cs @@ -2,7 +2,7 @@ using Eternelle.Common.Domain; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.DressCodeConfigs; -using Eternelle.Modules.Weddings.Domain.Shared; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Weddings; namespace Eternelle.Modules.Weddings.Application.DressCodeConfigs.CreateDressCodeConfig; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/CreateDressCodeConfig/CreateDressCodeConfigCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/CreateDressCodeConfig/CreateDressCodeConfigCommandValidator.cs index 1aa74d7..efe198f 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/CreateDressCodeConfig/CreateDressCodeConfigCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/CreateDressCodeConfig/CreateDressCodeConfigCommandValidator.cs @@ -1,4 +1,4 @@ -using Eternelle.Modules.Weddings.Domain.Shared; +using Eternelle.Common.Domain.Text; using FluentValidation; namespace Eternelle.Modules.Weddings.Application.DressCodeConfigs.CreateDressCodeConfig; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/GetDressCodeConfig/GetDressCodeConfigQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/GetDressCodeConfig/GetDressCodeConfigQueryHandler.cs index d450e78..d7fd7f7 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/GetDressCodeConfig/GetDressCodeConfigQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/GetDressCodeConfig/GetDressCodeConfigQueryHandler.cs @@ -14,7 +14,7 @@ public async Task> Handle( GetDressCodeConfigQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/UpdateDressCodeConfig/UpdateDressCodeConfigCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/UpdateDressCodeConfig/UpdateDressCodeConfigCommandHandler.cs index 8a7e61d..95b45e4 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/UpdateDressCodeConfig/UpdateDressCodeConfigCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/UpdateDressCodeConfig/UpdateDressCodeConfigCommandHandler.cs @@ -2,7 +2,7 @@ using Eternelle.Common.Domain; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.DressCodeConfigs; -using Eternelle.Modules.Weddings.Domain.Shared; +using Eternelle.Common.Domain.Text; namespace Eternelle.Modules.Weddings.Application.DressCodeConfigs.UpdateDressCodeConfig; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/UpdateDressCodeConfig/UpdateDressCodeConfigCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/UpdateDressCodeConfig/UpdateDressCodeConfigCommandValidator.cs index e6b95b6..be730b5 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/UpdateDressCodeConfig/UpdateDressCodeConfigCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/DressCodeConfigs/UpdateDressCodeConfig/UpdateDressCodeConfigCommandValidator.cs @@ -1,4 +1,4 @@ -using Eternelle.Modules.Weddings.Domain.Shared; +using Eternelle.Common.Domain.Text; using FluentValidation; namespace Eternelle.Modules.Weddings.Application.DressCodeConfigs.UpdateDressCodeConfig; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/AddEntourageMember/AddEntourageMemberCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/AddEntourageMember/AddEntourageMemberCommandHandler.cs index da89e4b..2277f7c 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/AddEntourageMember/AddEntourageMemberCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/AddEntourageMember/AddEntourageMemberCommandHandler.cs @@ -1,5 +1,7 @@ using Eternelle.Common.Application.Messaging; using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using Eternelle.Modules.Weddings.Domain.Shared; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/AddEntourageMember/AddEntourageMemberCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/AddEntourageMember/AddEntourageMemberCommandValidator.cs index ef086c4..329a49a 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/AddEntourageMember/AddEntourageMemberCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/AddEntourageMember/AddEntourageMemberCommandValidator.cs @@ -1,3 +1,5 @@ +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/CreateEntourageGroup/CreateEntourageGroupCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/CreateEntourageGroup/CreateEntourageGroupCommandHandler.cs index cc11e56..ed37499 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/CreateEntourageGroup/CreateEntourageGroupCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/CreateEntourageGroup/CreateEntourageGroupCommandHandler.cs @@ -1,5 +1,6 @@ using Eternelle.Common.Application.Messaging; using Eternelle.Common.Domain; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using Eternelle.Modules.Weddings.Domain.Shared; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/CreateEntourageGroup/CreateEntourageGroupCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/CreateEntourageGroup/CreateEntourageGroupCommandValidator.cs index c62ae98..e877d45 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/CreateEntourageGroup/CreateEntourageGroupCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/CreateEntourageGroup/CreateEntourageGroupCommandValidator.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/GetEntourageGroups/GetEntourageGroupsQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/GetEntourageGroups/GetEntourageGroupsQueryHandler.cs index a22d841..16b779b 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/GetEntourageGroups/GetEntourageGroupsQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/GetEntourageGroups/GetEntourageGroupsQueryHandler.cs @@ -14,7 +14,7 @@ public async Task>> Handle( GetEntourageGroupsQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string groupsSql = $""" diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/PairEntourageCouple/PairEntourageCoupleCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/PairEntourageCouple/PairEntourageCoupleCommandHandler.cs index 4e0ecc6..68b8f45 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/PairEntourageCouple/PairEntourageCoupleCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/PairEntourageCouple/PairEntourageCoupleCommandHandler.cs @@ -1,5 +1,6 @@ using Eternelle.Common.Application.Messaging; using Eternelle.Common.Domain; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using Eternelle.Modules.Weddings.Domain.Shared; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageGroup/UpdateEntourageGroupCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageGroup/UpdateEntourageGroupCommandHandler.cs index cd51852..21edda5 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageGroup/UpdateEntourageGroupCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageGroup/UpdateEntourageGroupCommandHandler.cs @@ -1,5 +1,6 @@ using Eternelle.Common.Application.Messaging; using Eternelle.Common.Domain; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using Eternelle.Modules.Weddings.Domain.Shared; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageGroup/UpdateEntourageGroupCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageGroup/UpdateEntourageGroupCommandValidator.cs index 3f8e394..d5ca776 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageGroup/UpdateEntourageGroupCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageGroup/UpdateEntourageGroupCommandValidator.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageMember/UpdateEntourageMemberCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageMember/UpdateEntourageMemberCommandHandler.cs index ab04b26..c87a09a 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageMember/UpdateEntourageMemberCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageMember/UpdateEntourageMemberCommandHandler.cs @@ -1,5 +1,7 @@ using Eternelle.Common.Application.Messaging; using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using Eternelle.Modules.Weddings.Domain.Shared; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageMember/UpdateEntourageMemberCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageMember/UpdateEntourageMemberCommandValidator.cs index c3b09e5..3c38283 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageMember/UpdateEntourageMemberCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/EntourageGroups/UpdateEntourageMember/UpdateEntourageMemberCommandValidator.cs @@ -1,3 +1,5 @@ +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GalleryImages/GetGalleryImages/GetGalleryImagesQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GalleryImages/GetGalleryImages/GetGalleryImagesQueryHandler.cs index 742f1de..354e14e 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GalleryImages/GetGalleryImages/GetGalleryImagesQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GalleryImages/GetGalleryImages/GetGalleryImagesQueryHandler.cs @@ -13,7 +13,7 @@ public async Task>> Handle( GetGalleryImagesQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/CreateGiftOption/CreateGiftOptionCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/CreateGiftOption/CreateGiftOptionCommandHandler.cs index 75f3e4e..8518f49 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/CreateGiftOption/CreateGiftOptionCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/CreateGiftOption/CreateGiftOptionCommandHandler.cs @@ -2,6 +2,7 @@ using Eternelle.Common.Domain; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.GiftOptions; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/CreateGiftOption/CreateGiftOptionCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/CreateGiftOption/CreateGiftOptionCommandValidator.cs index acf7ab7..b50754c 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/CreateGiftOption/CreateGiftOptionCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/CreateGiftOption/CreateGiftOptionCommandValidator.cs @@ -1,4 +1,5 @@ using Eternelle.Modules.Weddings.Domain.GiftOptions; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/GetGiftOptions/GetGiftOptionsQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/GetGiftOptions/GetGiftOptionsQueryHandler.cs index fbe2d6c..c4b6cda 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/GetGiftOptions/GetGiftOptionsQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/GetGiftOptions/GetGiftOptionsQueryHandler.cs @@ -13,7 +13,7 @@ public async Task>> Handle( GetGiftOptionsQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/UpdateGiftOption/UpdateGiftOptionCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/UpdateGiftOption/UpdateGiftOptionCommandHandler.cs index 2773365..b203b94 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/UpdateGiftOption/UpdateGiftOptionCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/UpdateGiftOption/UpdateGiftOptionCommandHandler.cs @@ -2,6 +2,7 @@ using Eternelle.Common.Domain; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.GiftOptions; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; namespace Eternelle.Modules.Weddings.Application.GiftOptions.UpdateGiftOption; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/UpdateGiftOption/UpdateGiftOptionCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/UpdateGiftOption/UpdateGiftOptionCommandValidator.cs index 9292ad5..52e40ec 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/UpdateGiftOption/UpdateGiftOptionCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GiftOptions/UpdateGiftOption/UpdateGiftOptionCommandValidator.cs @@ -1,4 +1,5 @@ using Eternelle.Modules.Weddings.Domain.GiftOptions; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetGuestPhotos/GetGuestPhotosQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetGuestPhotos/GetGuestPhotosQueryHandler.cs index b45bf99..5561c43 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetGuestPhotos/GetGuestPhotosQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetGuestPhotos/GetGuestPhotosQueryHandler.cs @@ -14,7 +14,7 @@ public async Task>> Handle( GetGuestPhotosQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); string sql = $""" diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetPublicGuestPhotos/GetPublicGuestPhotosQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetPublicGuestPhotos/GetPublicGuestPhotosQueryHandler.cs index 0c6a2ef..0e243e6 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetPublicGuestPhotos/GetPublicGuestPhotosQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetPublicGuestPhotos/GetPublicGuestPhotosQueryHandler.cs @@ -14,7 +14,7 @@ public async Task>> Handle( GetPublicGuestPhotosQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); string sql = $""" diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetUploadContext/GetUploadContextQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetUploadContext/GetUploadContextQueryHandler.cs index cb6a0d6..820120a 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetUploadContext/GetUploadContextQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/GetUploadContext/GetUploadContextQueryHandler.cs @@ -14,7 +14,7 @@ public async Task> Handle( GetUploadContextQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); // Resolve the wedding from the upload token stored on SnapShareConfig. // Returns a generic not-found so callers cannot probe token validity diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/RegisterGuestPhotos/RegisterGuestPhotosCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/RegisterGuestPhotos/RegisterGuestPhotosCommandHandler.cs index 49f36d9..9a63a1e 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/RegisterGuestPhotos/RegisterGuestPhotosCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/RegisterGuestPhotos/RegisterGuestPhotosCommandHandler.cs @@ -1,6 +1,7 @@ using Eternelle.Common.Application.Clock; using Eternelle.Common.Application.Messaging; using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Application.Abstractions.Storage; using Eternelle.Modules.Weddings.Domain.GuestPhotos; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/RegisterGuestPhotos/RegisterGuestPhotosCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/RegisterGuestPhotos/RegisterGuestPhotosCommandValidator.cs index 30296d8..2a914e9 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/RegisterGuestPhotos/RegisterGuestPhotosCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/GuestPhotos/RegisterGuestPhotos/RegisterGuestPhotosCommandValidator.cs @@ -1,5 +1,5 @@ +using Eternelle.Common.Domain.People; using Eternelle.Modules.Weddings.Application.GuestPhotos.GenerateUploadSlots; -using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; namespace Eternelle.Modules.Weddings.Application.GuestPhotos.RegisterGuestPhotos; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/CreateReminder/CreateReminderCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/CreateReminder/CreateReminderCommandHandler.cs index f6c7e49..357b73b 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/CreateReminder/CreateReminderCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/CreateReminder/CreateReminderCommandHandler.cs @@ -2,6 +2,7 @@ using Eternelle.Common.Domain; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.Reminders; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/CreateReminder/CreateReminderCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/CreateReminder/CreateReminderCommandValidator.cs index 0b6d739..9a6b4af 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/CreateReminder/CreateReminderCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/CreateReminder/CreateReminderCommandValidator.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/GetReminders/GetRemindersQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/GetReminders/GetRemindersQueryHandler.cs index b0b005a..f749e1b 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/GetReminders/GetRemindersQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/GetReminders/GetRemindersQueryHandler.cs @@ -13,7 +13,7 @@ public async Task>> Handle( GetRemindersQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/UpdateReminder/UpdateReminderCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/UpdateReminder/UpdateReminderCommandHandler.cs index bbb78e9..23ce1b8 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/UpdateReminder/UpdateReminderCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/UpdateReminder/UpdateReminderCommandHandler.cs @@ -2,6 +2,7 @@ using Eternelle.Common.Domain; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.Reminders; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; namespace Eternelle.Modules.Weddings.Application.Reminders.UpdateReminder; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/UpdateReminder/UpdateReminderCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/UpdateReminder/UpdateReminderCommandValidator.cs index e898679..eee2051 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/UpdateReminder/UpdateReminderCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Reminders/UpdateReminder/UpdateReminderCommandValidator.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/CreateStoryMoment/CreateStoryMomentCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/CreateStoryMoment/CreateStoryMomentCommandHandler.cs index 1c62fe8..d68e839 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/CreateStoryMoment/CreateStoryMomentCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/CreateStoryMoment/CreateStoryMomentCommandHandler.cs @@ -1,6 +1,7 @@ using Eternelle.Common.Application.Messaging; using Eternelle.Common.Domain; using Eternelle.Modules.Weddings.Application.Abstractions.Data; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.StoryMoments; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/CreateStoryMoment/CreateStoryMomentCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/CreateStoryMoment/CreateStoryMomentCommandValidator.cs index 42408f7..eff905b 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/CreateStoryMoment/CreateStoryMomentCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/CreateStoryMoment/CreateStoryMomentCommandValidator.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/GetStoryMoments/GetStoryMomentsQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/GetStoryMoments/GetStoryMomentsQueryHandler.cs index 112c508..9db8a24 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/GetStoryMoments/GetStoryMomentsQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/GetStoryMoments/GetStoryMomentsQueryHandler.cs @@ -13,7 +13,7 @@ public async Task>> Handle( GetStoryMomentsQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/UpdateStoryMoment/UpdateStoryMomentCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/UpdateStoryMoment/UpdateStoryMomentCommandHandler.cs index c765d10..7c1c378 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/UpdateStoryMoment/UpdateStoryMomentCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/UpdateStoryMoment/UpdateStoryMomentCommandHandler.cs @@ -1,6 +1,7 @@ using Eternelle.Common.Application.Messaging; using Eternelle.Common.Domain; using Eternelle.Modules.Weddings.Application.Abstractions.Data; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.StoryMoments; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/UpdateStoryMoment/UpdateStoryMomentCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/UpdateStoryMoment/UpdateStoryMomentCommandValidator.cs index efd36bb..5ae2e98 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/UpdateStoryMoment/UpdateStoryMomentCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/StoryMoments/UpdateStoryMoment/UpdateStoryMomentCommandValidator.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using FluentValidation; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/VendorCredits/GetVendorCredits/GetVendorCreditsQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/VendorCredits/GetVendorCredits/GetVendorCreditsQueryHandler.cs index 3428191..3dea1ac 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/VendorCredits/GetVendorCredits/GetVendorCreditsQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/VendorCredits/GetVendorCredits/GetVendorCreditsQueryHandler.cs @@ -13,7 +13,7 @@ public async Task>> Handle( GetVendorCreditsQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/AddPartner/AddPartnerCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/AddPartner/AddPartnerCommandHandler.cs index f5ebeca..b02513c 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/AddPartner/AddPartnerCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/AddPartner/AddPartnerCommandHandler.cs @@ -1,6 +1,7 @@ using Eternelle.Common.Application.Clock; using Eternelle.Common.Application.Messaging; using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/AddPartner/AddPartnerCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/AddPartner/AddPartnerCommandValidator.cs index 79f065e..8a9d233 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/AddPartner/AddPartnerCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/AddPartner/AddPartnerCommandValidator.cs @@ -1,4 +1,4 @@ -using Eternelle.Modules.Weddings.Domain.Shared; +using Eternelle.Common.Domain.People; using FluentValidation; namespace Eternelle.Modules.Weddings.Application.Weddings.AddPartner; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/GetWedding/GetWeddingQueryHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/GetWedding/GetWeddingQueryHandler.cs index 8b45061..0712f0a 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/GetWedding/GetWeddingQueryHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/GetWedding/GetWeddingQueryHandler.cs @@ -12,7 +12,7 @@ internal sealed class GetWeddingQueryHandler(IDbConnectionFactory dbConnectionFa { public async Task> Handle(GetWeddingQuery query, CancellationToken cancellationToken) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); const string sql = $""" @@ -42,7 +42,7 @@ FROM wedding.profiles p Dictionary weddingDictionary = []; await connection.QueryAsync( - sql, + new CommandDefinition(sql, query, cancellationToken: cancellationToken), (wedding, partner, snapShare) => { if (weddingDictionary.TryGetValue(wedding.Id, out WeddingResponse? existingWedding)) @@ -66,7 +66,6 @@ FROM wedding.profiles p return wedding; }, - query, splitOn: $"{nameof(PartnerResponse.PartnerId)},{nameof(SnapShareResponse.SnapShareId)}"); if (!weddingDictionary.TryGetValue(query.WeddingId, out WeddingResponse? weddingResponse)) diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/UpdatePartner/UpdatePartnerCommandHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/UpdatePartner/UpdatePartnerCommandHandler.cs index eab9330..cc92f2c 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/UpdatePartner/UpdatePartnerCommandHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/UpdatePartner/UpdatePartnerCommandHandler.cs @@ -1,6 +1,7 @@ using Eternelle.Common.Application.Clock; using Eternelle.Common.Application.Messaging; using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; using Eternelle.Modules.Weddings.Application.Abstractions.Data; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/UpdatePartner/UpdatePartnerCommandValidator.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/UpdatePartner/UpdatePartnerCommandValidator.cs index 6241cc5..cc472ce 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/UpdatePartner/UpdatePartnerCommandValidator.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Application/Weddings/UpdatePartner/UpdatePartnerCommandValidator.cs @@ -1,4 +1,4 @@ -using Eternelle.Modules.Weddings.Domain.Shared; +using Eternelle.Common.Domain.People; using FluentValidation; namespace Eternelle.Modules.Weddings.Application.Weddings.UpdatePartner; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/CeremonyActs/CeremonyAct.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/CeremonyActs/CeremonyAct.cs index 18eeefc..788a54f 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/CeremonyActs/CeremonyAct.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/CeremonyActs/CeremonyAct.cs @@ -1,4 +1,5 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/DressCodeConfigs/DressCodeConfig.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/DressCodeConfigs/DressCodeConfig.cs index 1c0ec14..b198889 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/DressCodeConfigs/DressCodeConfig.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/DressCodeConfigs/DressCodeConfig.cs @@ -1,4 +1,5 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageCouple.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageCouple.cs index 31f3f46..e201407 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageCouple.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageCouple.cs @@ -1,4 +1,5 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; namespace Eternelle.Modules.Weddings.Domain.EntourageGroups; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageGroup.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageGroup.cs index 5a4a158..08b7146 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageGroup.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageGroup.cs @@ -1,4 +1,6 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageMember.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageMember.cs index 41c21a0..05608f3 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageMember.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/EntourageGroups/EntourageMember.cs @@ -1,4 +1,6 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/GiftOptions/GiftOption.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/GiftOptions/GiftOption.cs index c5c1425..a4ee90f 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/GiftOptions/GiftOption.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/GiftOptions/GiftOption.cs @@ -1,4 +1,5 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/GuestPhotos/GuestPhoto.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/GuestPhotos/GuestPhoto.cs index 7993799..2dca5a1 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/GuestPhotos/GuestPhoto.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/GuestPhotos/GuestPhoto.cs @@ -1,4 +1,5 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Reminders/Reminder.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Reminders/Reminder.cs index 6030aee..e1e4189 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Reminders/Reminder.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Reminders/Reminder.cs @@ -1,4 +1,5 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/StoryMoments/StoryMoment.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/StoryMoments/StoryMoment.cs index 358ea38..0958274 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/StoryMoments/StoryMoment.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/StoryMoments/StoryMoment.cs @@ -1,4 +1,5 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Partner.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Partner.cs index a93a592..198410c 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Partner.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Partner.cs @@ -1,4 +1,5 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; using Eternelle.Modules.Weddings.Domain.Shared; namespace Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Wedding.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Wedding.cs index 4c631a4..301ff6d 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Wedding.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Domain/Weddings/Wedding.cs @@ -1,4 +1,5 @@ using Eternelle.Common.Domain; +using Eternelle.Common.Domain.People; using Eternelle.Modules.Weddings.Domain.Shared; namespace Eternelle.Modules.Weddings.Domain.Weddings; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/CeremonyActs/CeremonyActConfiguration.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/CeremonyActs/CeremonyActConfiguration.cs index 0d77174..ac7d9f2 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/CeremonyActs/CeremonyActConfiguration.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/CeremonyActs/CeremonyActConfiguration.cs @@ -1,4 +1,5 @@ using Eternelle.Modules.Weddings.Domain.CeremonyActs; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/DressCodeConfigs/DressCodeConfigConfiguration.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/DressCodeConfigs/DressCodeConfigConfiguration.cs index 9c7189e..767bbd7 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/DressCodeConfigs/DressCodeConfigConfiguration.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/DressCodeConfigs/DressCodeConfigConfiguration.cs @@ -1,5 +1,5 @@ using Eternelle.Modules.Weddings.Domain.DressCodeConfigs; -using Eternelle.Modules.Weddings.Domain.Shared; +using Eternelle.Common.Domain.Text; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageCoupleConfiguration.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageCoupleConfiguration.cs index 646feed..954c5cf 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageCoupleConfiguration.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageCoupleConfiguration.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using Eternelle.Modules.Weddings.Domain.Shared; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageGroupConfiguration.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageGroupConfiguration.cs index cdf4c2a..3182dd6 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageGroupConfiguration.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageGroupConfiguration.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using Eternelle.Modules.Weddings.Domain.Shared; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageMemberConfiguration.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageMemberConfiguration.cs index d590409..56a8241 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageMemberConfiguration.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/EntourageGroups/EntourageMemberConfiguration.cs @@ -1,3 +1,5 @@ +using Eternelle.Common.Domain.People; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.EntourageGroups; using Eternelle.Modules.Weddings.Domain.Shared; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/GiftOptions/GiftOptionConfiguration.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/GiftOptions/GiftOptionConfiguration.cs index c1548bc..9c8140d 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/GiftOptions/GiftOptionConfiguration.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/GiftOptions/GiftOptionConfiguration.cs @@ -1,4 +1,5 @@ using Eternelle.Modules.Weddings.Domain.GiftOptions; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/GuestPhotos/GuestPhotoConfiguration.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/GuestPhotos/GuestPhotoConfiguration.cs index e3ade91..7800c0c 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/GuestPhotos/GuestPhotoConfiguration.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/GuestPhotos/GuestPhotoConfiguration.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.People; using Eternelle.Modules.Weddings.Domain.GuestPhotos; using Eternelle.Modules.Weddings.Domain.Shared; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs index a516f84..acdece5 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Inbox/IdempotentIntegrationEventHandler.cs @@ -17,7 +17,7 @@ public override async Task Handle( TIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); var inboxMessageConsumer = new InboxMessageConsumer(integrationEvent.Id, decorated.GetType().Name); diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/IdempotentDomainEventHandler.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/IdempotentDomainEventHandler.cs index 1d74f65..f2f61ca 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/IdempotentDomainEventHandler.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Outbox/IdempotentDomainEventHandler.cs @@ -16,7 +16,7 @@ internal sealed class IdempotentDomainEventHandler( { public override async Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken = default) { - await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(); + await using DbConnection connection = await dbConnectionFactory.OpenConnectionAsync(cancellationToken); var outboxMessageConsumer = new OutboxMessageConsumer(domainEvent.Id, decorated.GetType().Name); diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Reminders/ReminderConfiguration.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Reminders/ReminderConfiguration.cs index 3f8bc3b..1c2f0bd 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Reminders/ReminderConfiguration.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Reminders/ReminderConfiguration.cs @@ -1,4 +1,5 @@ using Eternelle.Modules.Weddings.Domain.Reminders; +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/StoryMoments/StoryMomentConfiguration.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/StoryMoments/StoryMomentConfiguration.cs index 0e15a5c..fb4da07 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/StoryMoments/StoryMomentConfiguration.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/StoryMoments/StoryMomentConfiguration.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.Text; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.StoryMoments; using Microsoft.EntityFrameworkCore; diff --git a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Weddings/WeddingConfiguration.cs b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Weddings/WeddingConfiguration.cs index 1b70913..28b0ec6 100644 --- a/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Weddings/WeddingConfiguration.cs +++ b/src/Modules/Weddings/Eternelle.Modules.Weddings.Infrastructure/Weddings/WeddingConfiguration.cs @@ -1,3 +1,4 @@ +using Eternelle.Common.Domain.People; using Eternelle.Modules.Weddings.Domain.Shared; using Eternelle.Modules.Weddings.Domain.Weddings; using Microsoft.EntityFrameworkCore;