Initial commit: Books accounting system with EventFlow CQRS
Backend (.NET 10): - EventFlow CQRS/Event Sourcing with PostgreSQL - GraphQL.NET API with mutations and queries - Custom ReadModelSqlGenerator for snake_case PostgreSQL columns - Hangfire for background job processing - Integration tests with isolated test databases Frontend (React/Vite): - Initial project structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
66f6fa138d
126 changed files with 24741 additions and 0 deletions
355
backend/BACKEND_REQUIREMENTS.md
Normal file
355
backend/BACKEND_REQUIREMENTS.md
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
# Backend Krav - Dansk Bogføringssystem
|
||||
|
||||
## Overblik
|
||||
|
||||
Dette dokument beskriver backend-kravene for det danske bogføringssystem. Frontend er implementeret i React/TypeScript med Ant Design og Zustand state management.
|
||||
|
||||
---
|
||||
|
||||
## 1. Regnskabsår (Fiscal Years)
|
||||
|
||||
### 1.1 Data Model
|
||||
|
||||
```typescript
|
||||
interface FiscalYear {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string; // "2025" eller "2024/2025"
|
||||
startDate: string; // ISO date "YYYY-MM-DD"
|
||||
endDate: string; // ISO date "YYYY-MM-DD"
|
||||
status: 'open' | 'closed' | 'locked';
|
||||
openingBalancePosted: boolean;
|
||||
closingDate?: string; // Når året blev lukket
|
||||
closedBy?: string; // Bruger ID
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 API Endpoints
|
||||
|
||||
| Endpoint | Metode | Beskrivelse |
|
||||
|----------|--------|-------------|
|
||||
| `/api/fiscal-years` | GET | Hent alle regnskabsår for aktiv virksomhed |
|
||||
| `/api/fiscal-years` | POST | Opret nyt regnskabsår |
|
||||
| `/api/fiscal-years/:id` | GET | Hent specifikt regnskabsår |
|
||||
| `/api/fiscal-years/:id` | PATCH | Opdater regnskabsår |
|
||||
| `/api/fiscal-years/:id/close` | POST | Luk regnskabsår (årsafslutning) |
|
||||
| `/api/fiscal-years/:id/reopen` | POST | Genåbn lukket regnskabsår |
|
||||
| `/api/fiscal-years/:id/lock` | POST | Lås regnskabsår permanent |
|
||||
|
||||
### 1.3 Forretningsregler
|
||||
|
||||
#### Oprettelse
|
||||
- Nye regnskabsår må IKKE overlappe med eksisterende
|
||||
- Regnskabsår der "rører" ved grænsen (slutter/starter samme dag) er TILLADT
|
||||
- Understøt både kalenderår (jan-dec) og skæve regnskabsår (fx jul-jun)
|
||||
- Valider at regnskabsåret er mellem 300-400 dage (advarsel, ikke fejl)
|
||||
|
||||
#### Årsafslutning (Year-End Closing)
|
||||
**KRITISK**: Ved årsafslutning skal backend:
|
||||
|
||||
1. **Validere** at regnskabsåret kan lukkes:
|
||||
- Ikke allerede låst
|
||||
- Advar om åbne perioder (men tillad lukning)
|
||||
- Advar om ikke-afstemte transaktioner
|
||||
|
||||
2. **Oprette lukkeposter** (closing entries):
|
||||
- Luk alle indtægtskonti til resultatoverførselskonto
|
||||
- Luk alle udgiftskonti til resultatoverførselskonto
|
||||
- Gem som normale transaktioner med særlig markering (`isClosingEntry: true`)
|
||||
|
||||
3. **Generere åbningsbalancer** til næste regnskabsår:
|
||||
- Beregn slutsaldo for alle balancekonti (aktiver, passiver, egenkapital)
|
||||
- Nulstil resultatkonti (indtægter, udgifter, vareforbrug, personale, finansielle)
|
||||
- Opret åbningsposteringer i næste regnskabsår (`isOpeningBalance: true`)
|
||||
|
||||
4. **Opdatere status**:
|
||||
- Sæt regnskabsår status til 'closed' eller 'locked'
|
||||
- Luk alle tilhørende regnskabsperioder
|
||||
- Sæt `openingBalancePosted: true` på næste regnskabsår
|
||||
|
||||
#### Genåbning
|
||||
- Kun 'closed' regnskabsår kan genåbnes (ikke 'locked')
|
||||
- Ved genåbning skal åbningsbalancer i næste år opdateres automatisk
|
||||
- Log hvem der genåbnede og hvornår
|
||||
|
||||
### 1.4 Dynamiske Åbningsbalancer
|
||||
|
||||
Frontend forventer at åbningsbalancer opdateres automatisk når der bogføres i et tidligere år (som Dinero):
|
||||
|
||||
```
|
||||
Scenarie:
|
||||
1. Regnskabsår 2024 er lukket, 2025 er åbent
|
||||
2. Revisor finder fejl og bogfører korrektion i 2024
|
||||
3. Backend skal automatisk genberegne åbningsbalancen for 2025
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Regnskabsperioder (Accounting Periods)
|
||||
|
||||
### 2.1 Data Model
|
||||
|
||||
```typescript
|
||||
interface AccountingPeriod {
|
||||
id: string;
|
||||
fiscalYearId: string;
|
||||
companyId: string;
|
||||
name: string; // "Januar 2025", "Q1 2025"
|
||||
periodNumber: number; // 1-12 for månedlig
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: 'future' | 'open' | 'closed' | 'locked';
|
||||
closedAt?: string;
|
||||
closedBy?: string;
|
||||
reopenedAt?: string;
|
||||
reopenedBy?: string;
|
||||
lockedAt?: string;
|
||||
lockedBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
type PeriodFrequency = 'monthly' | 'quarterly' | 'half-yearly' | 'yearly';
|
||||
```
|
||||
|
||||
### 2.2 API Endpoints
|
||||
|
||||
| Endpoint | Metode | Beskrivelse |
|
||||
|----------|--------|-------------|
|
||||
| `/api/periods` | GET | Hent perioder (filtrer på fiscalYearId) |
|
||||
| `/api/periods/:id` | PATCH | Opdater periode |
|
||||
| `/api/periods/:id/close` | POST | Luk periode |
|
||||
| `/api/periods/:id/reopen` | POST | Genåbn periode |
|
||||
| `/api/periods/:id/lock` | POST | Lås periode |
|
||||
|
||||
### 2.3 Forretningsregler
|
||||
|
||||
- Perioder genereres automatisk ved oprettelse af regnskabsår
|
||||
- Understøt: månedlig (12), kvartalsvis (4), halvårlig (2), årlig (1)
|
||||
- Perioder kan kun lukkes i rækkefølge (periode 2 kræver periode 1 lukket)
|
||||
- Låste perioder kan IKKE genåbnes
|
||||
|
||||
---
|
||||
|
||||
## 3. Transaktioner med Periode-Reference
|
||||
|
||||
### 3.1 Udvidet Transaction Model
|
||||
|
||||
```typescript
|
||||
interface Transaction {
|
||||
id: string;
|
||||
companyId: string;
|
||||
fiscalYearId: string; // PÅKRÆVET - hvilket regnskabsår
|
||||
periodId: string; // PÅKRÆVET - hvilken periode
|
||||
journalEntryNumber: number;
|
||||
date: string;
|
||||
description: string;
|
||||
reference?: string;
|
||||
lines: TransactionLine[];
|
||||
isVoided: boolean;
|
||||
isReconciled: boolean;
|
||||
isClosingEntry?: boolean; // NY - markering for lukkepost
|
||||
isOpeningBalance?: boolean; // NY - markering for åbningsbalance
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Forretningsregler for Bogføring
|
||||
|
||||
```typescript
|
||||
interface PostingValidation {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
reasonDanish?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Backend skal validere ved bogføring:
|
||||
1. Find periode for transaktionsdato
|
||||
2. Tjek om perioden er åben
|
||||
3. Tjek om regnskabsåret er åbent
|
||||
4. Returner fejl med dansk besked hvis ikke tilladt
|
||||
|
||||
---
|
||||
|
||||
## 4. Kontoplan (Chart of Accounts)
|
||||
|
||||
### 4.1 Data Model
|
||||
|
||||
```typescript
|
||||
interface Account {
|
||||
id: string;
|
||||
companyId: string;
|
||||
accountNumber: string; // "1000", "3900"
|
||||
name: string;
|
||||
type: AccountType;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
vatCodeId?: string;
|
||||
isActive: boolean;
|
||||
isSystemAccount: boolean; // Kan ikke slettes
|
||||
balance: number; // Beregnet felt
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
type AccountType =
|
||||
| 'asset' // Aktiver
|
||||
| 'liability' // Passiver
|
||||
| 'equity' // Egenkapital
|
||||
| 'revenue' // Indtægter
|
||||
| 'cogs' // Vareforbrug
|
||||
| 'expense' // Udgifter
|
||||
| 'personnel' // Personaleomkostninger
|
||||
| 'financial' // Finansielle poster
|
||||
| 'extraordinary'; // Ekstraordinære poster
|
||||
```
|
||||
|
||||
### 4.2 Systemkonti
|
||||
|
||||
Følgende konti skal oprettes automatisk:
|
||||
- `3900` - Overført resultat (equity) - bruges til årsafslutning
|
||||
- Åbningsbalancekonto for primo-posteringer
|
||||
|
||||
---
|
||||
|
||||
## 5. Moms (VAT)
|
||||
|
||||
### 5.1 Data Model
|
||||
|
||||
```typescript
|
||||
interface VATCode {
|
||||
id: string;
|
||||
companyId: string;
|
||||
code: string; // "S25", "K25"
|
||||
name: string;
|
||||
rate: number; // 0.25 for 25%
|
||||
type: 'sales' | 'purchase' | 'eu_sales' | 'eu_purchase' | 'reverse_charge';
|
||||
accountId: string; // Momskonto
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface VATPeriod {
|
||||
id: string;
|
||||
companyId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
dueDate: string; // Indberetningsfrist
|
||||
status: 'open' | 'submitted' | 'paid';
|
||||
salesVAT: number; // Beregnet
|
||||
purchaseVAT: number; // Beregnet
|
||||
netVAT: number; // salesVAT - purchaseVAT
|
||||
submittedAt?: string;
|
||||
paidAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 SKAT Integration (Fremtidig)
|
||||
|
||||
- Momsindberetning til SKAT via NemVirksomhed API
|
||||
- Kvartalsvise eller månedlige momsperioder baseret på virksomhedsstørrelse
|
||||
|
||||
---
|
||||
|
||||
## 6. Virksomhed (Company)
|
||||
|
||||
### 6.1 Data Model
|
||||
|
||||
```typescript
|
||||
interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
cvr: string; // Dansk CVR-nummer
|
||||
address?: string;
|
||||
postalCode?: string;
|
||||
city?: string;
|
||||
country: string; // Default "DK"
|
||||
fiscalYearStart: number; // Måned 1-12 (default 1 for januar)
|
||||
currency: string; // Default "DKK"
|
||||
vatRegistered: boolean;
|
||||
vatPeriodFrequency: 'monthly' | 'quarterly' | 'half-yearly';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Fejlhåndtering
|
||||
|
||||
Alle fejlbeskeder skal være tilgængelige på både engelsk og dansk:
|
||||
|
||||
```typescript
|
||||
interface APIError {
|
||||
code: string;
|
||||
message: string;
|
||||
messageDanish: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### Eksempler på fejlkoder
|
||||
|
||||
| Kode | Engelsk | Dansk |
|
||||
|------|---------|-------|
|
||||
| `PERIOD_LOCKED` | Period is locked | Perioden er låst |
|
||||
| `FISCAL_YEAR_LOCKED` | Fiscal year is locked | Regnskabsåret er låst |
|
||||
| `OVERLAP_EXISTS` | Overlaps with existing fiscal year | Overlapper med eksisterende regnskabsår |
|
||||
| `UNBALANCED_ENTRY` | Debit and credit must be equal | Debet og kredit skal være ens |
|
||||
| `NO_COMPANY_SELECTED` | No company selected | Ingen virksomhed valgt |
|
||||
|
||||
---
|
||||
|
||||
## 8. Prioriteret Implementeringsrækkefølge
|
||||
|
||||
### Fase 1: Grundlæggende (MVP)
|
||||
1. Company CRUD
|
||||
2. Account CRUD med standard kontoplan
|
||||
3. Transaction CRUD med validering
|
||||
4. FiscalYear CRUD
|
||||
|
||||
### Fase 2: Periode-management
|
||||
1. AccountingPeriod generering og CRUD
|
||||
2. Periode-validering ved bogføring
|
||||
3. Periode lukning/åbning
|
||||
|
||||
### Fase 3: Årsafslutning
|
||||
1. Closing entries generering og bogføring
|
||||
2. Opening balance beregning og bogføring
|
||||
3. Dynamisk åbningsbalance-opdatering
|
||||
|
||||
### Fase 4: Moms
|
||||
1. VATCode CRUD
|
||||
2. VATPeriod generering
|
||||
3. Momsberegning og -rapportering
|
||||
|
||||
---
|
||||
|
||||
## 9. Tekniske Krav
|
||||
|
||||
### Authentication
|
||||
- JWT-baseret authentication
|
||||
- Multi-tenant support (bruger kan have adgang til flere virksomheder)
|
||||
|
||||
### Database
|
||||
- PostgreSQL anbefales
|
||||
- Soft delete på alle entiteter
|
||||
- Audit trail (createdBy, updatedBy, deletedBy)
|
||||
|
||||
### API Format
|
||||
- RESTful JSON API
|
||||
- GraphQL som alternativ (valgfrit)
|
||||
- Pagination på liste-endpoints
|
||||
- Filtering og sorting support
|
||||
|
||||
### Valuta
|
||||
- Alle beløb i øre/cents (integers) for at undgå floating point problemer
|
||||
- Frontend konverterer til/fra DKK ved visning
|
||||
|
||||
---
|
||||
|
||||
*Sidst opdateret: 17. januar 2026*
|
||||
37
backend/Books.Api.Tests/Books.Api.Tests.csproj
Normal file
37
backend/Books.Api.Tests/Books.Api.Tests.csproj
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>14</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Test Framework -->
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
|
||||
<!-- Integration Testing -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.3.0" />
|
||||
|
||||
<!-- Hangfire In-Memory for Tests -->
|
||||
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
|
||||
|
||||
<!-- PostgreSQL for Test Database -->
|
||||
<PackageReference Include="Npgsql" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Books.Api\Books.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
260
backend/Books.Api.Tests/GraphQL/CompanyGraphQLTests.cs
Normal file
260
backend/Books.Api.Tests/GraphQL/CompanyGraphQLTests.cs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
using Books.Api.Domain.Companies;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.Tests.Helpers;
|
||||
using Books.Api.Tests.Infrastructure;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Books.Api.Tests.GraphQL;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for Company GraphQL operations.
|
||||
/// Each test class runs with its own isolated database.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public class CompanyGraphQLTests(TestWebApplicationFactory factory)
|
||||
: IntegrationTestBase(factory)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Query_Companies_ReturnsEmptyList_WhenNoCompaniesExist()
|
||||
{
|
||||
// Arrange
|
||||
var graphqlClient = new GraphQLTestClient(Client);
|
||||
|
||||
// Act
|
||||
var response = await graphqlClient.QueryAsync<CompaniesResponse>("""
|
||||
query {
|
||||
companies {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
// Assert
|
||||
response.EnsureNoErrors();
|
||||
response.Data.Should().NotBeNull();
|
||||
response.Data!.Companies.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mutation_CreateCompany_CreatesCompanySuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var graphqlClient = new GraphQLTestClient(Client);
|
||||
|
||||
// Act
|
||||
var response = await graphqlClient.MutateAsync<CreateCompanyResponse>("""
|
||||
mutation CreateCompany($input: CreateCompanyInput!) {
|
||||
createCompany(input: $input) {
|
||||
id
|
||||
name
|
||||
cvr
|
||||
country
|
||||
currency
|
||||
fiscalYearStartMonth
|
||||
}
|
||||
}
|
||||
""",
|
||||
new
|
||||
{
|
||||
input = new
|
||||
{
|
||||
name = "Test Virksomhed A/S",
|
||||
cvr = "12345678",
|
||||
country = "DK",
|
||||
currency = "DKK",
|
||||
fiscalYearStartMonth = 1
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
response.EnsureNoErrors();
|
||||
response.Data.Should().NotBeNull();
|
||||
response.Data!.CreateCompany.Should().NotBeNull();
|
||||
response.Data.CreateCompany!.Name.Should().Be("Test Virksomhed A/S");
|
||||
response.Data.CreateCompany.Cvr.Should().Be("12345678");
|
||||
response.Data.CreateCompany.Country.Should().Be("DK");
|
||||
response.Data.CreateCompany.Currency.Should().Be("DKK");
|
||||
response.Data.CreateCompany.FiscalYearStartMonth.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_Company_ReturnsCompany_AfterCreation()
|
||||
{
|
||||
// Arrange
|
||||
var graphqlClient = new GraphQLTestClient(Client);
|
||||
|
||||
// Create a company first
|
||||
var createResponse = await graphqlClient.MutateAsync<CreateCompanyResponse>("""
|
||||
mutation CreateCompany($input: CreateCompanyInput!) {
|
||||
createCompany(input: $input) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
""",
|
||||
new
|
||||
{
|
||||
input = new
|
||||
{
|
||||
name = "Query Test Virksomhed",
|
||||
cvr = "87654321"
|
||||
}
|
||||
});
|
||||
|
||||
createResponse.EnsureNoErrors();
|
||||
var companyId = createResponse.Data!.CreateCompany!.Id;
|
||||
|
||||
// Act - Query the company by ID (with eventual consistency)
|
||||
var company = await Eventually.GetAsync(async () =>
|
||||
{
|
||||
var response = await graphqlClient.QueryAsync<CompanyResponse>("""
|
||||
query GetCompany($id: ID!) {
|
||||
company(id: $id) {
|
||||
id
|
||||
name
|
||||
cvr
|
||||
}
|
||||
}
|
||||
""",
|
||||
new { id = companyId });
|
||||
|
||||
return response.Data?.Company;
|
||||
});
|
||||
|
||||
// Assert
|
||||
company.Should().NotBeNull();
|
||||
company!.Name.Should().Be("Query Test Virksomhed");
|
||||
company.Cvr.Should().Be("87654321");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_Companies_ReturnsAllCompanies_AfterMultipleCreations()
|
||||
{
|
||||
// Arrange
|
||||
var graphqlClient = new GraphQLTestClient(Client);
|
||||
|
||||
// Create multiple companies with unique names for this test
|
||||
var company1Name = $"ListTest-Virksomhed-{Guid.NewGuid():N}";
|
||||
var company2Name = $"ListTest-Virksomhed-{Guid.NewGuid():N}";
|
||||
|
||||
await graphqlClient.MutateAsync<CreateCompanyResponse>($$"""
|
||||
mutation { createCompany(input: { name: "{{company1Name}}" }) { id } }
|
||||
""");
|
||||
|
||||
await graphqlClient.MutateAsync<CreateCompanyResponse>($$"""
|
||||
mutation { createCompany(input: { name: "{{company2Name}}" }) { id } }
|
||||
""");
|
||||
|
||||
// Act - Wait for eventual consistency (at least 2 companies with our prefix)
|
||||
var companies = await Eventually.GetAsync(
|
||||
async () =>
|
||||
{
|
||||
var response = await graphqlClient.QueryAsync<CompaniesResponse>("""
|
||||
query {
|
||||
companies {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
""");
|
||||
var allCompanies = response.Data?.Companies ?? [];
|
||||
var ourCompanies = allCompanies.Where(c => c.Name.StartsWith("ListTest-")).ToList();
|
||||
return ourCompanies.Count >= 2 ? ourCompanies : null;
|
||||
});
|
||||
|
||||
// Assert - verify our specific companies are present
|
||||
companies.Should().Contain(c => c.Name == company1Name);
|
||||
companies.Should().Contain(c => c.Name == company2Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mutation_UpdateCompany_UpdatesCompanySuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var graphqlClient = new GraphQLTestClient(Client);
|
||||
|
||||
// Create a company first
|
||||
var createResponse = await graphqlClient.MutateAsync<CreateCompanyResponse>("""
|
||||
mutation { createCompany(input: { name: "Original Name" }) { id } }
|
||||
""");
|
||||
|
||||
createResponse.EnsureNoErrors();
|
||||
var companyId = createResponse.Data!.CreateCompany!.Id;
|
||||
|
||||
// Wait for the company to be created
|
||||
await Eventually.GetAsync(async () =>
|
||||
{
|
||||
var repository = GetService<ICompanyRepository>();
|
||||
return await repository.GetByIds([CompanyId.With(companyId)]);
|
||||
});
|
||||
|
||||
// Act - Update the company
|
||||
var updateResponse = await graphqlClient.MutateAsync<UpdateCompanyResponse>("""
|
||||
mutation UpdateCompany($id: ID!, $input: UpdateCompanyInput!) {
|
||||
updateCompany(id: $id, input: $input) {
|
||||
id
|
||||
name
|
||||
cvr
|
||||
}
|
||||
}
|
||||
""",
|
||||
new
|
||||
{
|
||||
id = companyId,
|
||||
input = new
|
||||
{
|
||||
name = "Updated Name",
|
||||
cvr = "11111111"
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
updateResponse.EnsureNoErrors();
|
||||
|
||||
// Wait for eventual consistency and verify update
|
||||
var updatedCompany = await Eventually.GetAsync(async () =>
|
||||
{
|
||||
var repository = GetService<ICompanyRepository>();
|
||||
var companies = await repository.GetByIds([CompanyId.With(companyId)]);
|
||||
var company = companies.First();
|
||||
return company?.Name == "Updated Name" ? company : null;
|
||||
});
|
||||
|
||||
updatedCompany.Should().NotBeNull();
|
||||
updatedCompany!.Name.Should().Be("Updated Name");
|
||||
updatedCompany.Cvr.Should().Be("11111111");
|
||||
}
|
||||
|
||||
// Response DTOs for deserialization
|
||||
private class CompaniesResponse
|
||||
{
|
||||
public List<CompanyDto> Companies { get; set; } = [];
|
||||
}
|
||||
|
||||
private class CompanyResponse
|
||||
{
|
||||
public CompanyDto? Company { get; set; }
|
||||
}
|
||||
|
||||
private class CreateCompanyResponse
|
||||
{
|
||||
public CompanyDto? CreateCompany { get; set; }
|
||||
}
|
||||
|
||||
private class UpdateCompanyResponse
|
||||
{
|
||||
public CompanyDto? UpdateCompany { get; set; }
|
||||
}
|
||||
|
||||
private class CompanyDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Cvr { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? Currency { get; set; }
|
||||
public int? FiscalYearStartMonth { get; set; }
|
||||
}
|
||||
}
|
||||
89
backend/Books.Api.Tests/Helpers/Eventually.cs
Normal file
89
backend/Books.Api.Tests/Helpers/Eventually.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
namespace Books.Api.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for testing eventually consistent systems.
|
||||
/// Polls a condition until it's met or times out.
|
||||
/// </summary>
|
||||
public static class Eventually
|
||||
{
|
||||
/// <summary>
|
||||
/// Polls a condition until it returns true or times out.
|
||||
/// </summary>
|
||||
public static async Task AssertAsync(
|
||||
Func<Task<bool>> condition,
|
||||
TimeSpan? timeout = null,
|
||||
TimeSpan? pollInterval = null,
|
||||
string? failMessage = null)
|
||||
{
|
||||
timeout ??= TimeSpan.FromSeconds(5);
|
||||
pollInterval ??= TimeSpan.FromMilliseconds(50);
|
||||
|
||||
var start = DateTime.UtcNow;
|
||||
|
||||
while (DateTime.UtcNow - start < timeout)
|
||||
{
|
||||
if (await condition())
|
||||
return;
|
||||
|
||||
await Task.Delay(pollInterval.Value);
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
failMessage ?? $"Condition was not met within {timeout}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls until a non-null result is returned or times out.
|
||||
/// </summary>
|
||||
public static async Task<T> GetAsync<T>(
|
||||
Func<Task<T?>> getter,
|
||||
TimeSpan? timeout = null,
|
||||
TimeSpan? pollInterval = null,
|
||||
string? failMessage = null) where T : class
|
||||
{
|
||||
timeout ??= TimeSpan.FromSeconds(10);
|
||||
pollInterval ??= TimeSpan.FromMilliseconds(100);
|
||||
|
||||
var start = DateTime.UtcNow;
|
||||
|
||||
while (DateTime.UtcNow - start < timeout)
|
||||
{
|
||||
var result = await getter();
|
||||
if (result != null)
|
||||
return result;
|
||||
|
||||
await Task.Delay(pollInterval.Value);
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
failMessage ?? $"Expected non-null result was not returned within {timeout}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls until a collection has the expected count or times out.
|
||||
/// </summary>
|
||||
public static async Task<IReadOnlyList<T>> GetListAsync<T>(
|
||||
Func<Task<IReadOnlyList<T>>> getter,
|
||||
int expectedCount,
|
||||
TimeSpan? timeout = null,
|
||||
TimeSpan? pollInterval = null,
|
||||
string? failMessage = null)
|
||||
{
|
||||
timeout ??= TimeSpan.FromSeconds(10);
|
||||
pollInterval ??= TimeSpan.FromMilliseconds(100);
|
||||
|
||||
var start = DateTime.UtcNow;
|
||||
|
||||
while (DateTime.UtcNow - start < timeout)
|
||||
{
|
||||
var result = await getter();
|
||||
if (result.Count >= expectedCount)
|
||||
return result;
|
||||
|
||||
await Task.Delay(pollInterval.Value);
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
failMessage ?? $"Expected {expectedCount} items but condition was not met within {timeout}.");
|
||||
}
|
||||
}
|
||||
125
backend/Books.Api.Tests/Helpers/GraphQLTestClient.cs
Normal file
125
backend/Books.Api.Tests/Helpers/GraphQLTestClient.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Books.Api.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper client for executing GraphQL queries and mutations in tests.
|
||||
/// </summary>
|
||||
public class GraphQLTestClient(HttpClient httpClient)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Executes a GraphQL query and returns the typed result.
|
||||
/// </summary>
|
||||
public async Task<GraphQLResponse<T>> QueryAsync<T>(
|
||||
string query,
|
||||
object? variables = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ExecuteAsync<T>(query, variables, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a GraphQL mutation and returns the typed result.
|
||||
/// </summary>
|
||||
public async Task<GraphQLResponse<T>> MutateAsync<T>(
|
||||
string mutation,
|
||||
object? variables = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ExecuteAsync<T>(mutation, variables, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a GraphQL operation and returns the raw JSON response.
|
||||
/// </summary>
|
||||
public async Task<string> ExecuteRawAsync(
|
||||
string query,
|
||||
object? variables = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new GraphQLRequest(query, variables);
|
||||
var json = JsonSerializer.Serialize(request, JsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await httpClient.PostAsync("/graphql", content, cancellationToken);
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<GraphQLResponse<T>> ExecuteAsync<T>(
|
||||
string query,
|
||||
object? variables,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new GraphQLRequest(query, variables);
|
||||
var json = JsonSerializer.Serialize(request, JsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await httpClient.PostAsync("/graphql", content, cancellationToken);
|
||||
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
var result = JsonSerializer.Deserialize<GraphQLResponse<T>>(responseJson, JsonOptions);
|
||||
return result ?? throw new InvalidOperationException("Failed to deserialize GraphQL response");
|
||||
}
|
||||
}
|
||||
|
||||
public record GraphQLRequest(
|
||||
[property: JsonPropertyName("query")] string Query,
|
||||
[property: JsonPropertyName("variables")] object? Variables = null);
|
||||
|
||||
public class GraphQLResponse<T>
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public T? Data { get; set; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public List<GraphQLError>? Errors { get; set; }
|
||||
|
||||
public bool HasErrors => Errors?.Count > 0;
|
||||
|
||||
public void EnsureNoErrors()
|
||||
{
|
||||
if (HasErrors)
|
||||
{
|
||||
var errorDetails = Errors!.Select(e =>
|
||||
{
|
||||
var msg = e.Message;
|
||||
if (e.Extensions != null)
|
||||
{
|
||||
foreach (var ext in e.Extensions)
|
||||
{
|
||||
msg += $"{Environment.NewLine} [{ext.Key}]: {ext.Value}";
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
var messages = string.Join(Environment.NewLine, errorDetails);
|
||||
throw new GraphQLException($"GraphQL errors occurred:{Environment.NewLine}{messages}", Errors!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class GraphQLError
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public List<object>? Path { get; set; }
|
||||
|
||||
[JsonPropertyName("extensions")]
|
||||
public Dictionary<string, object>? Extensions { get; set; }
|
||||
}
|
||||
|
||||
public class GraphQLException(string message, List<GraphQLError> errors) : Exception(message)
|
||||
{
|
||||
public List<GraphQLError> Errors { get; } = errors;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using EventFlow;
|
||||
using EventFlow.Aggregates;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Books.Api.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for integration tests with isolated database per test class.
|
||||
/// Uses IClassFixture pattern - each test class gets its own database.
|
||||
/// </summary>
|
||||
public abstract class IntegrationTestBase
|
||||
: IClassFixture<TestWebApplicationFactory>, IDisposable
|
||||
{
|
||||
protected readonly TestWebApplicationFactory Factory;
|
||||
protected readonly HttpClient Client;
|
||||
private readonly IServiceScope _scope;
|
||||
|
||||
protected IntegrationTestBase(TestWebApplicationFactory factory)
|
||||
{
|
||||
Factory = factory;
|
||||
Client = factory.CreateGraphQLClient();
|
||||
_scope = factory.Services.CreateScope();
|
||||
}
|
||||
|
||||
protected IServiceProvider Services => _scope.ServiceProvider;
|
||||
|
||||
protected ICommandBus CommandBus => Services.GetRequiredService<ICommandBus>();
|
||||
protected IAggregateStore AggregateStore => Services.GetRequiredService<IAggregateStore>();
|
||||
|
||||
protected T GetService<T>() where T : notnull
|
||||
=> Services.GetRequiredService<T>();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_scope?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
121
backend/Books.Api.Tests/Infrastructure/TestDatabase.cs
Normal file
121
backend/Books.Api.Tests/Infrastructure/TestDatabase.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
using Npgsql;
|
||||
|
||||
namespace Books.Api.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an isolated PostgreSQL database for each test run.
|
||||
/// The database is automatically dropped when disposed.
|
||||
/// </summary>
|
||||
public class TestDatabase : IDisposable
|
||||
{
|
||||
public const string DefaultConnectionString =
|
||||
"Host=localhost;Port=5432;Username=postgres;Password=postgres;Database=postgres;Include Error Detail=true";
|
||||
|
||||
private readonly string _masterConnectionString;
|
||||
private bool _disposed;
|
||||
|
||||
public string DatabaseName { get; }
|
||||
|
||||
public string ConnectionString
|
||||
{
|
||||
get
|
||||
{
|
||||
var builder = new NpgsqlConnectionStringBuilder(_masterConnectionString)
|
||||
{
|
||||
Database = DatabaseName
|
||||
};
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public TestDatabase(string? masterConnectionString = null)
|
||||
{
|
||||
_masterConnectionString = masterConnectionString ?? GetConnectionStringFromEnvironment();
|
||||
DatabaseName = $"books_test_{Guid.NewGuid():N}";
|
||||
|
||||
CreateDatabase();
|
||||
}
|
||||
|
||||
private static string GetConnectionStringFromEnvironment()
|
||||
{
|
||||
return Environment.GetEnvironmentVariable("TEST_CONNECTION_STRING")
|
||||
?? DefaultConnectionString;
|
||||
}
|
||||
|
||||
private void CreateDatabase()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var connection = new NpgsqlConnection(_masterConnectionString);
|
||||
connection.Open();
|
||||
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = $"CREATE DATABASE \"{DatabaseName}\"";
|
||||
command.ExecuteNonQuery();
|
||||
|
||||
Console.WriteLine($"[TestDatabase] Created test database: {DatabaseName}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to create test database '{DatabaseName}' using connection string: {_masterConnectionString}",
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Step 1: Terminate all active connections to prevent "database in use" errors
|
||||
using (var connection = new NpgsqlConnection(_masterConnectionString))
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
using var terminateCommand = connection.CreateCommand();
|
||||
terminateCommand.CommandText = $@"
|
||||
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE pg_stat_activity.datname = '{DatabaseName}'
|
||||
AND pid <> pg_backend_pid();
|
||||
";
|
||||
terminateCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Step 2: Drop the database
|
||||
using (var connection = new NpgsqlConnection(_masterConnectionString))
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
using var dropCommand = connection.CreateCommand();
|
||||
dropCommand.CommandText = $"DROP DATABASE IF EXISTS \"{DatabaseName}\"";
|
||||
dropCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
Console.WriteLine($"[TestDatabase] Dropped test database: {DatabaseName}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Log but don't throw - we don't want cleanup failures to mask test failures
|
||||
Console.WriteLine($"[TestDatabase] Warning: Failed to drop test database {DatabaseName}: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
~TestDatabase()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
using Books.Api.EventFlow.Infrastructure;
|
||||
using EventFlow.PostgreSql;
|
||||
using EventFlow.PostgreSql.Connections;
|
||||
using EventFlow.PostgreSql.EventStores;
|
||||
using Hangfire;
|
||||
using Hangfire.InMemory;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Npgsql;
|
||||
|
||||
namespace Books.Api.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// WebApplicationFactory configured for integration testing with:
|
||||
/// - Isolated database per test class
|
||||
/// - In-memory Hangfire (no real job processing)
|
||||
/// - Full GraphQL endpoint support
|
||||
/// </summary>
|
||||
public class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
static TestWebApplicationFactory()
|
||||
{
|
||||
// Enable legacy timestamp behavior for Npgsql 6.0+
|
||||
// Must be set before any Npgsql connections are created
|
||||
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
||||
}
|
||||
|
||||
private readonly TestDatabase _testDatabase = new();
|
||||
|
||||
public string TestDatabaseName => _testDatabase.DatabaseName;
|
||||
public string ConnectionString => _testDatabase.ConnectionString;
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
// Set environment to Test to avoid Development-only middleware (like Altair)
|
||||
builder.UseEnvironment("Test");
|
||||
|
||||
builder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
// Clear all default configuration sources
|
||||
config.Sources.Clear();
|
||||
|
||||
// Add test configuration with isolated database
|
||||
var testConfig = new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Default"] = _testDatabase.ConnectionString,
|
||||
["AllowedHosts"] = "*"
|
||||
};
|
||||
|
||||
config.AddInMemoryCollection(testConfig);
|
||||
|
||||
Console.WriteLine($"[TestWebApplicationFactory] Using test database: {_testDatabase.DatabaseName}");
|
||||
Console.WriteLine($"[TestWebApplicationFactory] Connection string: {_testDatabase.ConnectionString}");
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Run database migrations on the test database BEFORE services are built
|
||||
// This ensures the test database has the correct schema
|
||||
MigrateTestDatabase();
|
||||
|
||||
// Replace the NpgsqlDataSource to use the test database
|
||||
services.RemoveAll<NpgsqlDataSource>();
|
||||
services.AddSingleton(new NpgsqlDataSourceBuilder(_testDatabase.ConnectionString).Build());
|
||||
|
||||
// CRITICAL: Replace EventFlow's PostgreSQL configuration with test connection string
|
||||
// This ensures the event store uses the test database, not the original
|
||||
services.RemoveAll<IPostgreSqlConfiguration>();
|
||||
services.AddSingleton<IPostgreSqlConfiguration>(
|
||||
PostgreSqlConfiguration.New.SetConnectionString(_testDatabase.ConnectionString));
|
||||
|
||||
// Replace only Hangfire storage (not EventFlow.Hangfire integration services)
|
||||
// We keep EventFlow.Hangfire services like IQueueNameProvider, HangfireJobScheduler
|
||||
var hangfireStorageDescriptors = services
|
||||
.Where(d =>
|
||||
d.ServiceType.FullName?.Contains("Hangfire.JobStorage") == true ||
|
||||
d.ServiceType.FullName?.Contains("Hangfire.Server.BackgroundJobServer") == true ||
|
||||
d.ImplementationType?.FullName?.Contains("Hangfire.PostgreSql") == true)
|
||||
.ToList();
|
||||
|
||||
foreach (var descriptor in hangfireStorageDescriptors)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
// Add Hangfire with in-memory storage
|
||||
services.AddHangfire(config => config
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UseInMemoryStorage());
|
||||
|
||||
services.AddHangfireServer(options =>
|
||||
{
|
||||
options.WorkerCount = 1;
|
||||
options.Queues = ["default"];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected override IHost CreateHost(IHostBuilder builder)
|
||||
{
|
||||
var host = base.CreateHost(builder);
|
||||
|
||||
// Run EventFlow PostgreSQL migrations after host is built
|
||||
using var scope = host.Services.CreateScope();
|
||||
var eventFlowMigrator = scope.ServiceProvider.GetRequiredService<IPostgreSqlDatabaseMigrator>();
|
||||
EventFlowEventStoresPostgreSql.MigrateDatabaseAsync(eventFlowMigrator, CancellationToken.None).Wait();
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
private void MigrateTestDatabase()
|
||||
{
|
||||
// Run DbUp migrations for read model tables
|
||||
DatabaseMigrator.Migrate(_testDatabase.ConnectionString);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// Reset Hangfire's static LogProvider before disposing to prevent
|
||||
// ObjectDisposedException when the next test class creates a new factory.
|
||||
Hangfire.Logging.LogProvider.SetCurrentLogProvider(null);
|
||||
|
||||
_testDatabase.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HttpClient configured for GraphQL requests.
|
||||
/// </summary>
|
||||
public HttpClient CreateGraphQLClient()
|
||||
{
|
||||
var client = CreateClient();
|
||||
client.BaseAddress = new Uri("http://localhost/graphql");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
10
backend/Books.Api.Tests/UnitTest1.cs
Normal file
10
backend/Books.Api.Tests/UnitTest1.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace Books.Api.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
5
backend/Books.Api.Tests/xunit.runner.json
Normal file
5
backend/Books.Api.Tests/xunit.runner.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
46
backend/Books.Api/Books.Api.csproj
Normal file
46
backend/Books.Api/Books.Api.csproj
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>14</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EventFlow (local source) -->
|
||||
<ProjectReference Include="../../../EventFlow/Source/EventFlow/EventFlow.csproj" />
|
||||
<ProjectReference Include="../../../EventFlow/Source/EventFlow.PostgreSql/EventFlow.PostgreSql.csproj" />
|
||||
<ProjectReference Include="../../../EventFlow/Source/EventFlow.Hangfire/EventFlow.Hangfire.csproj" />
|
||||
|
||||
<!-- Database -->
|
||||
<PackageReference Include="Npgsql" Version="10.0.1" />
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="dbup-postgresql" Version="5.0.40" />
|
||||
|
||||
<!-- Hangfire -->
|
||||
<PackageReference Include="Hangfire.Core" Version="1.8.22" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.22" />
|
||||
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.13" />
|
||||
|
||||
<!-- Serialization -->
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
|
||||
<!-- GraphQL -->
|
||||
<PackageReference Include="GraphQL" Version="8.0.2" />
|
||||
<PackageReference Include="GraphQL.Server.Transports.AspNetCore" Version="8.0.2" />
|
||||
<PackageReference Include="GraphQL.Server.Ui.Altair" Version="8.0.2" />
|
||||
<PackageReference Include="GraphQL.DataLoader" Version="8.0.2" />
|
||||
<PackageReference Include="GraphQL.MicrosoftDI" Version="8.0.2" />
|
||||
|
||||
<!-- DI Decoration -->
|
||||
<PackageReference Include="Scrutor" Version="5.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Database\Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
backend/Books.Api/Books.Api.http
Normal file
6
backend/Books.Api/Books.Api.http
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
@Books.Api_HostAddress = http://localhost:5142
|
||||
|
||||
GET {{Books.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
using Books.Api.Domain.Companies;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.Companies;
|
||||
|
||||
public class CreateCompanyCommandHandler : CommandHandler<CompanyAggregate, CompanyId, CreateCompanyCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
CompanyAggregate aggregate,
|
||||
CreateCompanyCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
aggregate.Create(
|
||||
command.Name,
|
||||
command.Cvr,
|
||||
command.Address,
|
||||
command.PostalCode,
|
||||
command.City,
|
||||
command.Country,
|
||||
command.FiscalYearStartMonth,
|
||||
command.Currency,
|
||||
command.VatRegistered,
|
||||
command.VatPeriodFrequency);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateCompanyCommandHandler : CommandHandler<CompanyAggregate, CompanyId, UpdateCompanyCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
CompanyAggregate aggregate,
|
||||
UpdateCompanyCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
aggregate.Update(
|
||||
command.Name,
|
||||
command.Cvr,
|
||||
command.Address,
|
||||
command.PostalCode,
|
||||
command.City,
|
||||
command.Country,
|
||||
command.FiscalYearStartMonth,
|
||||
command.Currency,
|
||||
command.VatRegistered,
|
||||
command.VatPeriodFrequency);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
56
backend/Books.Api/Commands/Companies/CompanyCommands.cs
Normal file
56
backend/Books.Api/Commands/Companies/CompanyCommands.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
using Books.Api.Domain.Companies;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.Companies;
|
||||
|
||||
public class CreateCompanyCommand(
|
||||
CompanyId aggregateId,
|
||||
string name,
|
||||
string? cvr,
|
||||
string? address,
|
||||
string? postalCode,
|
||||
string? city,
|
||||
string country,
|
||||
int fiscalYearStartMonth,
|
||||
string currency,
|
||||
bool vatRegistered,
|
||||
string? vatPeriodFrequency)
|
||||
: Command<CompanyAggregate, CompanyId>(aggregateId)
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public string? Cvr { get; } = cvr;
|
||||
public string? Address { get; } = address;
|
||||
public string? PostalCode { get; } = postalCode;
|
||||
public string? City { get; } = city;
|
||||
public string Country { get; } = country;
|
||||
public int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
|
||||
public string Currency { get; } = currency;
|
||||
public bool VatRegistered { get; } = vatRegistered;
|
||||
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
|
||||
}
|
||||
|
||||
public class UpdateCompanyCommand(
|
||||
CompanyId aggregateId,
|
||||
string name,
|
||||
string? cvr,
|
||||
string? address,
|
||||
string? postalCode,
|
||||
string? city,
|
||||
string country,
|
||||
int fiscalYearStartMonth,
|
||||
string currency,
|
||||
bool vatRegistered,
|
||||
string? vatPeriodFrequency)
|
||||
: Command<CompanyAggregate, CompanyId>(aggregateId)
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public string? Cvr { get; } = cvr;
|
||||
public string? Address { get; } = address;
|
||||
public string? PostalCode { get; } = postalCode;
|
||||
public string? City { get; } = city;
|
||||
public string Country { get; } = country;
|
||||
public int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
|
||||
public string Currency { get; } = currency;
|
||||
public bool VatRegistered { get; } = vatRegistered;
|
||||
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
|
||||
}
|
||||
25
backend/Books.Api/Controllers/WeatherForecastController.cs
Normal file
25
backend/Books.Api/Controllers/WeatherForecastController.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Books.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class WeatherForecastController : ControllerBase
|
||||
{
|
||||
private static readonly string[] Summaries =
|
||||
[
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
];
|
||||
|
||||
[HttpGet(Name = "GetWeatherForecast")]
|
||||
public IEnumerable<WeatherForecast> Get()
|
||||
{
|
||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
110
backend/Books.Api/Database/Migrations/001_Initial.sql
Normal file
110
backend/Books.Api/Database/Migrations/001_Initial.sql
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
-- 001_Initial.sql
|
||||
-- Creates read model tables for Books API
|
||||
-- NOTE: Column names use snake_case (PostgreSQL convention)
|
||||
-- Custom ReadModelSqlGenerator converts C# PascalCase to snake_case
|
||||
|
||||
-- Company read models
|
||||
CREATE TABLE IF NOT EXISTS company_read_models (
|
||||
-- EventFlow standard columns
|
||||
aggregate_id VARCHAR(255) PRIMARY KEY,
|
||||
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
|
||||
|
||||
-- Business columns (snake_case)
|
||||
name VARCHAR(255) NOT NULL,
|
||||
cvr VARCHAR(8),
|
||||
address VARCHAR(500),
|
||||
postal_code VARCHAR(10),
|
||||
city VARCHAR(100),
|
||||
country VARCHAR(2) NOT NULL DEFAULT 'DK',
|
||||
fiscal_year_start_month SMALLINT NOT NULL DEFAULT 1,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'DKK',
|
||||
vat_registered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
vat_period_frequency VARCHAR(20)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_company_cvr ON company_read_models(cvr) WHERE cvr IS NOT NULL;
|
||||
|
||||
-- Fiscal year read models
|
||||
CREATE TABLE IF NOT EXISTS fiscal_year_read_models (
|
||||
aggregate_id VARCHAR(255) PRIMARY KEY,
|
||||
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
|
||||
|
||||
company_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
opening_balance_posted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
closing_date TIMESTAMPTZ,
|
||||
closed_by VARCHAR(255),
|
||||
|
||||
CONSTRAINT fk_fiscal_year_company
|
||||
FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_fiscal_year_status
|
||||
CHECK (status IN ('open', 'closed', 'locked'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fiscal_year_company ON fiscal_year_read_models(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fiscal_year_dates ON fiscal_year_read_models(start_date, end_date);
|
||||
|
||||
-- Accounting period read models
|
||||
CREATE TABLE IF NOT EXISTS accounting_period_read_models (
|
||||
aggregate_id VARCHAR(255) PRIMARY KEY,
|
||||
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
|
||||
|
||||
fiscal_year_id VARCHAR(255) NOT NULL,
|
||||
company_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
period_number SMALLINT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'future',
|
||||
closed_at TIMESTAMPTZ,
|
||||
closed_by VARCHAR(255),
|
||||
reopened_at TIMESTAMPTZ,
|
||||
reopened_by VARCHAR(255),
|
||||
locked_at TIMESTAMPTZ,
|
||||
locked_by VARCHAR(255),
|
||||
|
||||
CONSTRAINT fk_period_fiscal_year
|
||||
FOREIGN KEY (fiscal_year_id) REFERENCES fiscal_year_read_models(aggregate_id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_period_status
|
||||
CHECK (status IN ('future', 'open', 'closed', 'locked'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_period_fiscal_year ON accounting_period_read_models(fiscal_year_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_period_company ON accounting_period_read_models(company_id);
|
||||
|
||||
-- Account read models (chart of accounts)
|
||||
CREATE TABLE IF NOT EXISTS account_read_models (
|
||||
aggregate_id VARCHAR(255) PRIMARY KEY,
|
||||
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
|
||||
|
||||
company_id VARCHAR(255) NOT NULL,
|
||||
account_number VARCHAR(10) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
account_type VARCHAR(20) NOT NULL,
|
||||
parent_id VARCHAR(255),
|
||||
description TEXT,
|
||||
vat_code_id VARCHAR(255),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_system_account BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
CONSTRAINT fk_account_company
|
||||
FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_account_type
|
||||
CHECK (account_type IN ('asset', 'liability', 'equity', 'revenue', 'cogs', 'expense', 'personnel', 'financial', 'extraordinary'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_number
|
||||
ON account_read_models(company_id, account_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_company ON account_read_models(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_type ON account_read_models(account_type);
|
||||
81
backend/Books.Api/Domain/Companies/CompanyAggregate.cs
Normal file
81
backend/Books.Api/Domain/Companies/CompanyAggregate.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using Books.Api.Domain.Companies.Events;
|
||||
using EventFlow.Aggregates;
|
||||
|
||||
namespace Books.Api.Domain.Companies;
|
||||
|
||||
public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, CompanyId>(id)
|
||||
{
|
||||
private bool _isCreated;
|
||||
|
||||
public void Apply(CompanyCreatedEvent e) => _isCreated = true;
|
||||
|
||||
public void Apply(CompanyUpdatedEvent e) { }
|
||||
|
||||
public void Create(
|
||||
string name,
|
||||
string? cvr,
|
||||
string? address,
|
||||
string? postalCode,
|
||||
string? city,
|
||||
string country,
|
||||
int fiscalYearStartMonth,
|
||||
string currency,
|
||||
bool vatRegistered,
|
||||
string? vatPeriodFrequency)
|
||||
{
|
||||
if (_isCreated)
|
||||
throw new DomainException("Company already exists");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new DomainException("Company name is required");
|
||||
|
||||
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
|
||||
throw new DomainException("Fiscal year start month must be between 1 and 12");
|
||||
|
||||
Emit(new CompanyCreatedEvent(
|
||||
name.Trim(),
|
||||
cvr?.Trim(),
|
||||
address?.Trim(),
|
||||
postalCode?.Trim(),
|
||||
city?.Trim(),
|
||||
country,
|
||||
fiscalYearStartMonth,
|
||||
currency,
|
||||
vatRegistered,
|
||||
vatPeriodFrequency));
|
||||
}
|
||||
|
||||
public void Update(
|
||||
string name,
|
||||
string? cvr,
|
||||
string? address,
|
||||
string? postalCode,
|
||||
string? city,
|
||||
string country,
|
||||
int fiscalYearStartMonth,
|
||||
string currency,
|
||||
bool vatRegistered,
|
||||
string? vatPeriodFrequency)
|
||||
{
|
||||
if (!_isCreated)
|
||||
throw new DomainException("Company does not exist");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new DomainException("Company name is required");
|
||||
|
||||
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
|
||||
throw new DomainException("Fiscal year start month must be between 1 and 12");
|
||||
|
||||
Emit(new CompanyUpdatedEvent(
|
||||
name.Trim(),
|
||||
cvr?.Trim(),
|
||||
address?.Trim(),
|
||||
postalCode?.Trim(),
|
||||
city?.Trim(),
|
||||
country,
|
||||
fiscalYearStartMonth,
|
||||
currency,
|
||||
vatRegistered,
|
||||
vatPeriodFrequency));
|
||||
}
|
||||
}
|
||||
8
backend/Books.Api/Domain/Companies/CompanyId.cs
Normal file
8
backend/Books.Api/Domain/Companies/CompanyId.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
using EventFlow.Core;
|
||||
|
||||
namespace Books.Api.Domain.Companies;
|
||||
|
||||
public class CompanyId : Identity<CompanyId>
|
||||
{
|
||||
public CompanyId(string value) : base(value) { }
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using EventFlow.Aggregates;
|
||||
|
||||
namespace Books.Api.Domain.Companies.Events;
|
||||
|
||||
public class CompanyCreatedEvent(
|
||||
string name,
|
||||
string? cvr,
|
||||
string? address,
|
||||
string? postalCode,
|
||||
string? city,
|
||||
string country,
|
||||
int fiscalYearStartMonth,
|
||||
string currency,
|
||||
bool vatRegistered,
|
||||
string? vatPeriodFrequency) : AggregateEvent<CompanyAggregate, CompanyId>
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public string? Cvr { get; } = cvr;
|
||||
public string? Address { get; } = address;
|
||||
public string? PostalCode { get; } = postalCode;
|
||||
public string? City { get; } = city;
|
||||
public string Country { get; } = country;
|
||||
public int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
|
||||
public string Currency { get; } = currency;
|
||||
public bool VatRegistered { get; } = vatRegistered;
|
||||
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using EventFlow.Aggregates;
|
||||
|
||||
namespace Books.Api.Domain.Companies.Events;
|
||||
|
||||
public class CompanyUpdatedEvent(
|
||||
string name,
|
||||
string? cvr,
|
||||
string? address,
|
||||
string? postalCode,
|
||||
string? city,
|
||||
string country,
|
||||
int fiscalYearStartMonth,
|
||||
string currency,
|
||||
bool vatRegistered,
|
||||
string? vatPeriodFrequency) : AggregateEvent<CompanyAggregate, CompanyId>
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public string? Cvr { get; } = cvr;
|
||||
public string? Address { get; } = address;
|
||||
public string? PostalCode { get; } = postalCode;
|
||||
public string? City { get; } = city;
|
||||
public string Country { get; } = country;
|
||||
public int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
|
||||
public string Currency { get; } = currency;
|
||||
public bool VatRegistered { get; } = vatRegistered;
|
||||
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
|
||||
}
|
||||
20
backend/Books.Api/Domain/DomainException.cs
Normal file
20
backend/Books.Api/Domain/DomainException.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
namespace Books.Api.Domain;
|
||||
|
||||
public class DomainException : Exception
|
||||
{
|
||||
public string Code { get; }
|
||||
public string? MessageDanish { get; }
|
||||
|
||||
public DomainException(string message)
|
||||
: base(message)
|
||||
{
|
||||
Code = "DOMAIN_ERROR";
|
||||
}
|
||||
|
||||
public DomainException(string code, string message, string messageDanish)
|
||||
: base(message)
|
||||
{
|
||||
Code = code;
|
||||
MessageDanish = messageDanish;
|
||||
}
|
||||
}
|
||||
0
backend/Books.Api/Endpoints/ApiEndpoints.cs
Normal file
0
backend/Books.Api/Endpoints/ApiEndpoints.cs
Normal file
31
backend/Books.Api/EventFlow/Customs/Casing.cs
Normal file
31
backend/Books.Api/EventFlow/Customs/Casing.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System.Reflection;
|
||||
|
||||
namespace Books.Api.EventFlow.Customs;
|
||||
|
||||
public static class Casing
|
||||
{
|
||||
public static string ToSnakeCase(this string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
var stringBuilder = new System.Text.StringBuilder();
|
||||
for (var i = 0; i < input.Length; i++)
|
||||
{
|
||||
var c = input[i];
|
||||
if (char.IsUpper(c))
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
stringBuilder.Append('_');
|
||||
}
|
||||
stringBuilder.Append(char.ToLower(c));
|
||||
}
|
||||
else
|
||||
{
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
}
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
}
|
||||
221
backend/Books.Api/EventFlow/Customs/ReadModelSqlGenerator.cs
Normal file
221
backend/Books.Api/EventFlow/Customs/ReadModelSqlGenerator.cs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
// The MIT License (MIT)
|
||||
//
|
||||
// Copyright (c) 2015-2025 Rasmus Mikkelsen
|
||||
// https://github.com/eventflow/EventFlow
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
// the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
// subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Reflection;
|
||||
using EventFlow.Extensions;
|
||||
using EventFlow.ReadStores;
|
||||
using EventFlow.Sql.ReadModels;
|
||||
using EventFlow.Sql.ReadModels.Attributes;
|
||||
|
||||
namespace Books.Api.EventFlow.Customs;
|
||||
|
||||
/// <summary>
|
||||
/// Custom SQL generator for PostgreSQL that uses snake_case column names
|
||||
/// while keeping PascalCase property names for Dapper parameter binding.
|
||||
/// </summary>
|
||||
public class ReadModelSqlGenerator : IReadModelSqlGenerator
|
||||
{
|
||||
private static readonly ConcurrentDictionary<Type, string> TableNames = new();
|
||||
private static readonly ConcurrentDictionary<Type, IReadOnlyCollection<PropertyInfo>> PropertyInfos = new();
|
||||
private static readonly ConcurrentDictionary<Type, string> IdentityColumns = new();
|
||||
private static readonly ConcurrentDictionary<Type, string> IdentityProperties = new();
|
||||
private static readonly ConcurrentDictionary<Type, string> VersionColumns = new();
|
||||
|
||||
private readonly ConcurrentDictionary<Type, string?> _insertSqls = new();
|
||||
private readonly ConcurrentDictionary<Type, string> _purgeSqls = new();
|
||||
private readonly ConcurrentDictionary<Type, string> _deleteSqls = new();
|
||||
private readonly ConcurrentDictionary<Type, string> _selectSqls = new();
|
||||
private readonly ConcurrentDictionary<Type, string> _updateSqls = new();
|
||||
|
||||
public string? CreateInsertSql<TReadModel>() where TReadModel : IReadModel
|
||||
{
|
||||
var readModelType = typeof(TReadModel);
|
||||
if (_insertSqls.TryGetValue(readModelType, out var sql))
|
||||
{
|
||||
return sql;
|
||||
}
|
||||
|
||||
var properties = GetPropertyInfos(readModelType);
|
||||
|
||||
// Column names in snake_case
|
||||
var columnList = string.Join(", ", properties.Select(p => p.Name.ToSnakeCase()));
|
||||
|
||||
// Parameter names use property names (PascalCase) for Dapper binding
|
||||
var parameterList = string.Join(", ", properties.Select(p => $"@{p.Name}"));
|
||||
|
||||
sql = $"INSERT INTO {GetTableName<TReadModel>()} ({columnList}) VALUES ({parameterList})";
|
||||
|
||||
_insertSqls[readModelType] = sql;
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
public string CreateSelectSql<TReadModel>() where TReadModel : IReadModel
|
||||
{
|
||||
var readModelType = typeof(TReadModel);
|
||||
if (_selectSqls.TryGetValue(readModelType, out var sql))
|
||||
{
|
||||
return sql;
|
||||
}
|
||||
|
||||
var tableName = GetTableName<TReadModel>();
|
||||
var identityColumn = GetIdentityColumn<TReadModel>();
|
||||
var properties = GetPropertyInfos(readModelType);
|
||||
|
||||
// Use explicit column aliases to map snake_case columns to PascalCase properties
|
||||
var columnList = string.Join(", ", properties.Select(p => $"{p.Name.ToSnakeCase()} AS \"{p.Name}\""));
|
||||
|
||||
sql = $"SELECT {columnList} FROM {tableName} WHERE {identityColumn} = @EventFlowReadModelId";
|
||||
|
||||
_selectSqls[readModelType] = sql;
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
public string CreateDeleteSql<TReadModel>() where TReadModel : IReadModel
|
||||
{
|
||||
var readModelType = typeof(TReadModel);
|
||||
if (_deleteSqls.TryGetValue(readModelType, out var sql))
|
||||
{
|
||||
return sql;
|
||||
}
|
||||
|
||||
sql = $"DELETE FROM {GetTableName<TReadModel>()} WHERE {GetIdentityColumn<TReadModel>()} = @EventFlowReadModelId";
|
||||
_deleteSqls[readModelType] = sql;
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
public string CreateUpdateSql<TReadModel>() where TReadModel : IReadModel
|
||||
{
|
||||
var readModelType = typeof(TReadModel);
|
||||
if (_updateSqls.TryGetValue(readModelType, out var sql))
|
||||
{
|
||||
return sql;
|
||||
}
|
||||
|
||||
var identityColumn = GetIdentityColumn<TReadModel>();
|
||||
var identityProperty = GetIdentityProperty<TReadModel>();
|
||||
var versionColumn = GetVersionColumn<TReadModel>();
|
||||
|
||||
var versionCheck = string.IsNullOrEmpty(versionColumn)
|
||||
? string.Empty
|
||||
: $"AND {versionColumn} = @_PREVIOUS_VERSION";
|
||||
|
||||
var properties = GetPropertyInfos(readModelType);
|
||||
var updateColumns = properties
|
||||
.Where(p => p.Name.ToSnakeCase() != identityColumn)
|
||||
.Select(p => $"{p.Name.ToSnakeCase()} = @{p.Name}");
|
||||
|
||||
var tableName = GetTableName<TReadModel>();
|
||||
|
||||
sql = $"UPDATE {tableName} SET {string.Join(", ", updateColumns)} WHERE {identityColumn} = @{identityProperty} {versionCheck}";
|
||||
|
||||
_updateSqls[readModelType] = sql;
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
public string CreatePurgeSql<TReadModel>() where TReadModel : IReadModel
|
||||
{
|
||||
return _purgeSqls.GetOrCreate(typeof(TReadModel), t => $"DELETE FROM {GetTableName(t)}");
|
||||
}
|
||||
|
||||
public string GetTableName<TReadModel>() where TReadModel : IReadModel
|
||||
{
|
||||
return GetTableName(typeof(TReadModel));
|
||||
}
|
||||
|
||||
private static string GetTableName(Type readModelType)
|
||||
{
|
||||
return TableNames.GetOrAdd(
|
||||
readModelType,
|
||||
t =>
|
||||
{
|
||||
var tableAttribute = t.GetTypeInfo().GetCustomAttribute<TableAttribute>(false);
|
||||
var table = string.IsNullOrEmpty(tableAttribute?.Name)
|
||||
? $"ReadModel-{t.Name.Replace("ReadModel", string.Empty)}"
|
||||
: tableAttribute.Name;
|
||||
return string.IsNullOrEmpty(tableAttribute?.Schema)
|
||||
? $"\"{table}\""
|
||||
: $"\"{tableAttribute?.Schema}\".\"{table}\"";
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetIdentityColumn<TReadModel>()
|
||||
{
|
||||
return IdentityColumns.GetOrAdd(
|
||||
typeof(TReadModel),
|
||||
t =>
|
||||
{
|
||||
var propertyInfo = GetPropertyInfos(t)
|
||||
.SingleOrDefault(
|
||||
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelIdentityColumnAttribute));
|
||||
return (propertyInfo?.Name ?? "AggregateId").ToSnakeCase();
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetIdentityProperty<TReadModel>()
|
||||
{
|
||||
return IdentityProperties.GetOrAdd(
|
||||
typeof(TReadModel),
|
||||
t =>
|
||||
{
|
||||
var propertyInfo = GetPropertyInfos(t)
|
||||
.SingleOrDefault(
|
||||
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelIdentityColumnAttribute));
|
||||
return propertyInfo?.Name ?? "AggregateId";
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetVersionColumn<TReadModel>()
|
||||
{
|
||||
return VersionColumns.GetOrAdd(
|
||||
typeof(TReadModel),
|
||||
t =>
|
||||
{
|
||||
var propertyInfo = GetPropertyInfos(t)
|
||||
.SingleOrDefault(
|
||||
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelVersionColumnAttribute));
|
||||
if (propertyInfo != null)
|
||||
{
|
||||
return propertyInfo.Name.ToSnakeCase();
|
||||
}
|
||||
|
||||
return GetPropertyInfos(t).Any(n => n.Name == "LastAggregateSequenceNumber")
|
||||
? "last_aggregate_sequence_number"
|
||||
: string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<PropertyInfo> GetPropertyInfos(Type readModelType)
|
||||
{
|
||||
return PropertyInfos.GetOrAdd(
|
||||
readModelType,
|
||||
t => t.GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
||||
.Where(p => !p.GetCustomAttributes().Any(a => a is SqlReadModelIgnoreColumnAttribute))
|
||||
.OrderBy(p => p.Name)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using Books.Api.EventFlow.ReadModels;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using EventFlow;
|
||||
using EventFlow.Extensions;
|
||||
using EventFlow.PostgreSql.Extensions;
|
||||
using EventFlow.Sql.ReadModels;
|
||||
using ReadModelSqlGenerator = Books.Api.EventFlow.Customs.ReadModelSqlGenerator;
|
||||
|
||||
namespace Books.Api.EventFlow.Extensions;
|
||||
|
||||
public static class ReadModelRegistrationExtensions
|
||||
{
|
||||
public static IEventFlowOptions AddReadModels(this IEventFlowOptions options)
|
||||
{
|
||||
return options
|
||||
.UsePostgreSqlReadModel<CompanyReadModel, CompanyReadModelLocator>()
|
||||
.RegisterServices( sr => sr.AddSingleton<IReadModelSqlGenerator>(new ReadModelSqlGenerator()));
|
||||
}
|
||||
|
||||
public static IServiceCollection AddRepositories(this IServiceCollection services)
|
||||
{
|
||||
// Register locators
|
||||
services.AddTransient<CompanyReadModelLocator>();
|
||||
|
||||
// Register repositories
|
||||
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
using System.Reflection;
|
||||
using Dapper;
|
||||
using DbUp;
|
||||
using Npgsql;
|
||||
|
||||
namespace Books.Api.EventFlow.Infrastructure;
|
||||
|
||||
public static class DatabaseMigrator
|
||||
{
|
||||
public static void Migrate(string connectionString)
|
||||
{
|
||||
EnsureDatabaseExists(connectionString);
|
||||
|
||||
var upgrader = DeployChanges.To
|
||||
.PostgresqlDatabase(connectionString)
|
||||
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
||||
.LogToConsole()
|
||||
.Build();
|
||||
|
||||
var result = upgrader.PerformUpgrade();
|
||||
if (!result.Successful)
|
||||
throw result.Error;
|
||||
}
|
||||
|
||||
private static void EnsureDatabaseExists(string connectionString)
|
||||
{
|
||||
var builder = new NpgsqlConnectionStringBuilder(connectionString);
|
||||
var database = builder.Database;
|
||||
builder.Database = "postgres";
|
||||
|
||||
using var connection = new NpgsqlConnection(builder.ConnectionString);
|
||||
connection.Open();
|
||||
|
||||
var exists = connection.ExecuteScalar<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = @db)",
|
||||
new { db = database });
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
connection.Execute($"CREATE DATABASE \"{database}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
using System.Reflection;
|
||||
using Books.Api.Infrastructure;
|
||||
using EventFlow.Aggregates;
|
||||
using EventFlow.EventStores;
|
||||
using EventFlow.Subscribers;
|
||||
using Hangfire;
|
||||
|
||||
namespace Books.Api.EventFlow.Infrastructure;
|
||||
|
||||
#pragma warning disable CS9113 // Parameter is unread
|
||||
public class DispatchToSubscriberResilienceStrategy(
|
||||
ILogger<DispatchToSubscriberResilienceStrategy> logger,
|
||||
IScheduler scheduler,
|
||||
IServiceProvider serviceProvider,
|
||||
IEventJsonSerializer eventJsonSerializer) : IDispatchToSubscriberResilienceStrategy
|
||||
#pragma warning restore CS9113
|
||||
{
|
||||
public Task BeforeHandleEventAsync(
|
||||
ISubscribe subscriberTo,
|
||||
IDomainEvent domainEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task HandleEventFailedAsync(
|
||||
ISubscribe subscriberTo,
|
||||
IDomainEvent domainEvent,
|
||||
Exception exception,
|
||||
bool swallowException,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var subscriberType = GetSubscriberType(subscriberTo);
|
||||
var eventType = domainEvent.EventType.Name;
|
||||
var aggregateId = domainEvent.GetIdentity()?.Value ?? "unknown";
|
||||
|
||||
logger.LogError(exception,
|
||||
"[RESILIENCE] Subscriber {SubscriberType} failed to handle event {EventType} for aggregate {AggregateId}",
|
||||
subscriberType.Name,
|
||||
eventType,
|
||||
aggregateId);
|
||||
|
||||
try
|
||||
{
|
||||
var serializedEvent = eventJsonSerializer.Serialize(domainEvent);
|
||||
var domainEventInterface = domainEvent.GetType().GetTypeInfo()
|
||||
.GetInterfaces()
|
||||
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDomainEvent<,,>));
|
||||
|
||||
if (domainEventInterface != null)
|
||||
{
|
||||
scheduler.EnqueueJob<SubscriberRetryJob>(
|
||||
job => job.RetryEventDispatchAsync(
|
||||
subscriberType.AssemblyQualifiedName!,
|
||||
domainEventInterface.AssemblyQualifiedName!,
|
||||
serializedEvent.SerializedData,
|
||||
serializedEvent.SerializedMetadata),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
logger.LogWarning(
|
||||
"[RESILIENCE] Scheduled retry job for {SubscriberType} / {EventType} / {AggregateId}",
|
||||
subscriberType.Name,
|
||||
eventType,
|
||||
aggregateId);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e,
|
||||
"[RESILIENCE] Failed to schedule retry for {SubscriberType} / {EventType}. " +
|
||||
"This event will NOT be retried and may cause data inconsistency.",
|
||||
subscriberType.Name,
|
||||
eventType);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task HandleEventSucceededAsync(
|
||||
ISubscribe subscriberTo,
|
||||
IDomainEvent domainEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task BeforeDispatchToSubscribersAsync(
|
||||
IDomainEvent domainEvent,
|
||||
IReadOnlyCollection<IDomainEvent> domainEvents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DispatchToSubscribersSucceededAsync(
|
||||
IDomainEvent domainEvent,
|
||||
IReadOnlyCollection<IDomainEvent> domainEvents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> HandleDispatchToSubscribersFailedAsync(
|
||||
IDomainEvent domainEvent,
|
||||
IReadOnlyCollection<IDomainEvent> domainEvents,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
private static Type GetSubscriberType(ISubscribe subscriberTo)
|
||||
{
|
||||
if (subscriberTo is ISubscribeDecorator decorator)
|
||||
{
|
||||
return GetSubscriberType(decorator.InnerInstance as ISubscribe
|
||||
?? throw new InvalidOperationException("InnerInstance is not ISubscribe"));
|
||||
}
|
||||
|
||||
return subscriberTo.GetType();
|
||||
}
|
||||
}
|
||||
|
||||
public interface ISubscribeDecorator
|
||||
{
|
||||
object InnerInstance { get; }
|
||||
}
|
||||
|
||||
public class SubscriberRetryJob(
|
||||
ILogger<SubscriberRetryJob> logger,
|
||||
IServiceProvider serviceProvider,
|
||||
IEventJsonSerializer eventJsonSerializer)
|
||||
{
|
||||
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
|
||||
public async Task RetryEventDispatchAsync(
|
||||
string subscriberTypeName,
|
||||
string domainEventTypeName,
|
||||
string serializedData,
|
||||
string serializedMetadata)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[RETRY] Retrying event dispatch to {SubscriberType}",
|
||||
subscriberTypeName);
|
||||
try
|
||||
{
|
||||
var domainEvent = eventJsonSerializer.Deserialize(serializedData, serializedMetadata);
|
||||
var subscriberType = Type.GetType(subscriberTypeName);
|
||||
|
||||
if (subscriberType == null)
|
||||
{
|
||||
logger.LogError("[RETRY] Could not resolve subscriber type: {SubscriberType}", subscriberTypeName);
|
||||
return;
|
||||
}
|
||||
|
||||
var subscriber = FindSubscriberInstance(subscriberType, domainEvent);
|
||||
if (subscriber == null)
|
||||
{
|
||||
logger.LogError("[RETRY] Could not find subscriber instance for {SubscriberType}", subscriberTypeName);
|
||||
return;
|
||||
}
|
||||
|
||||
var domainEventType = domainEvent.GetType();
|
||||
var handleAsyncMethod = subscriber.GetType().GetMethods()
|
||||
.FirstOrDefault(m => m.Name == "HandleAsync" &&
|
||||
m.GetParameters().Any(p => p.ParameterType.IsAssignableFrom(domainEventType)));
|
||||
|
||||
if (handleAsyncMethod != null)
|
||||
{
|
||||
await (Task)handleAsyncMethod.Invoke(subscriber, [domainEvent, CancellationToken.None])!;
|
||||
logger.LogInformation("[RETRY] Successfully retried event dispatch to {SubscriberType}", subscriberTypeName);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError(
|
||||
"[RETRY] No matching HandleAsync method found for {SubscriberType} and {DomainEventType}",
|
||||
subscriberTypeName,
|
||||
domainEventTypeName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[RETRY] Failed to retry event dispatch to {SubscriberType}", subscriberTypeName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private object? FindSubscriberInstance(Type subscriberType, object domainEvent)
|
||||
{
|
||||
var interfaceType = ExtractSubscriberInterface(subscriberType, domainEvent);
|
||||
if (interfaceType == null)
|
||||
return null;
|
||||
|
||||
var instances = serviceProvider.GetServices(interfaceType);
|
||||
return instances.FirstOrDefault(x =>
|
||||
x?.GetType() == subscriberType ||
|
||||
HasInnerType(x, subscriberType));
|
||||
}
|
||||
|
||||
private static Type? ExtractSubscriberInterface(Type subscriberType, object domainEvent)
|
||||
{
|
||||
var domainEventType = domainEvent.GetType();
|
||||
var interfaces = subscriberType.GetInterfaces()
|
||||
.Where(i => i.IsGenericType &&
|
||||
(i.GetGenericTypeDefinition() == typeof(ISubscribeAsynchronousTo<,,>) ||
|
||||
i.GetGenericTypeDefinition() == typeof(ISubscribeSynchronousTo<,,>)));
|
||||
|
||||
foreach (var iface in interfaces)
|
||||
{
|
||||
var genericArguments = iface.GetGenericArguments();
|
||||
var eventGenericArgs = domainEventType.GenericTypeArguments;
|
||||
if (eventGenericArgs.Length >= 3 && genericArguments[2] == eventGenericArgs[2])
|
||||
{
|
||||
return iface;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasInnerType(object? obj, Type concreteType)
|
||||
{
|
||||
if (obj == null) return false;
|
||||
|
||||
var decoratorType = obj.GetType();
|
||||
var field = decoratorType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.FirstOrDefault(x => x.Name.Contains("inner", StringComparison.InvariantCultureIgnoreCase));
|
||||
var property = decoratorType.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.FirstOrDefault(x => x.Name.Contains("inner", StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (field?.GetValue(obj)?.GetType() == concreteType)
|
||||
return true;
|
||||
|
||||
if (property?.GetValue(obj)?.GetType() == concreteType)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
using Books.Api.Infrastructure;
|
||||
using EventFlow.Aggregates;
|
||||
using EventFlow.ReadStores;
|
||||
using Hangfire;
|
||||
|
||||
namespace Books.Api.EventFlow.Infrastructure;
|
||||
|
||||
public class ReadStoresResilienceStrategy(
|
||||
ILogger<ReadStoresResilienceStrategy> logger,
|
||||
IScheduler scheduler) : IDispatchToReadStoresResilienceStrategy
|
||||
{
|
||||
public Task BeforeUpdateAsync(
|
||||
IReadStoreManager readStoreManager,
|
||||
IReadOnlyCollection<IDomainEvent> domainEvents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> HandleUpdateFailedAsync(
|
||||
IReadStoreManager readStoreManager,
|
||||
IReadOnlyCollection<IDomainEvent> domainEvents,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var firstEvent = domainEvents.FirstOrDefault();
|
||||
var aggregateId = firstEvent?.GetIdentity()?.Value ?? "unknown";
|
||||
var readModelType = readStoreManager.ReadModelType.Name;
|
||||
|
||||
logger.LogError(exception,
|
||||
"[RESILIENCE] Failed to update read model {ReadModelType} for aggregate {AggregateId}. " +
|
||||
"Events: {EventCount}. This may cause data inconsistency.",
|
||||
readModelType,
|
||||
aggregateId,
|
||||
domainEvents.Count);
|
||||
|
||||
// Schedule a retry job to repopulate the read model
|
||||
if (firstEvent != null)
|
||||
{
|
||||
var aggregateType = firstEvent.AggregateType.Name;
|
||||
|
||||
scheduler.EnqueueJob<ReadModelRepopulationJob>(
|
||||
job => job.RepopulateReadModelAsync(
|
||||
aggregateType,
|
||||
aggregateId,
|
||||
readModelType),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
logger.LogWarning(
|
||||
"[RESILIENCE] Scheduled read model repopulation job for {ReadModelType} / {AggregateId}",
|
||||
readModelType,
|
||||
aggregateId);
|
||||
}
|
||||
|
||||
// Return false to indicate we handled the failure (don't rethrow)
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task UpdateSucceededAsync(
|
||||
IReadStoreManager readStoreManager,
|
||||
IReadOnlyCollection<IDomainEvent> domainEvents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public class ReadModelRepopulationJob(ILogger<ReadModelRepopulationJob> logger)
|
||||
{
|
||||
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
|
||||
public Task RepopulateReadModelAsync(
|
||||
string aggregateType,
|
||||
string aggregateId,
|
||||
string readModelType)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[REPOPULATION] Starting read model repopulation for {ReadModelType} / {AggregateId}",
|
||||
readModelType,
|
||||
aggregateId);
|
||||
|
||||
// For now, we log the repopulation attempt
|
||||
// A full implementation would:
|
||||
// 1. Load all events for the aggregate from the event store
|
||||
// 2. Clear the existing read model entry
|
||||
// 3. Replay all events to rebuild the read model
|
||||
|
||||
logger.LogWarning(
|
||||
"[REPOPULATION] Read model repopulation for {ReadModelType} / {AggregateId} " +
|
||||
"requires manual intervention. Check data consistency.",
|
||||
readModelType,
|
||||
aggregateId);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
80
backend/Books.Api/EventFlow/ReadModels/CompanyReadModel.cs
Normal file
80
backend/Books.Api/EventFlow/ReadModels/CompanyReadModel.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Books.Api.Domain.Companies;
|
||||
using Books.Api.Domain.Companies.Events;
|
||||
using EventFlow.Aggregates;
|
||||
using EventFlow.PostgreSql.ReadStores.Attributes;
|
||||
using EventFlow.ReadStores;
|
||||
|
||||
namespace Books.Api.EventFlow.ReadModels;
|
||||
|
||||
[Table("company_read_models")]
|
||||
public class CompanyReadModel : IReadModel,
|
||||
IAmReadModelFor<CompanyAggregate, CompanyId, CompanyCreatedEvent>,
|
||||
IAmReadModelFor<CompanyAggregate, CompanyId, CompanyUpdatedEvent>
|
||||
{
|
||||
// EventFlow standard columns
|
||||
[PostgreSqlReadModelIdentityColumn]
|
||||
public string AggregateId { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreateTime { get; set; }
|
||||
public DateTimeOffset UpdatedTime { get; set; }
|
||||
|
||||
[PostgreSqlReadModelVersionColumn]
|
||||
public int LastAggregateSequenceNumber { get; set; }
|
||||
|
||||
// Business columns
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Cvr { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string Country { get; set; } = "DK";
|
||||
public int FiscalYearStartMonth { get; set; } = 1;
|
||||
public string Currency { get; set; } = "DKK";
|
||||
public bool VatRegistered { get; set; }
|
||||
public string? VatPeriodFrequency { get; set; }
|
||||
|
||||
public Task ApplyAsync(
|
||||
IReadModelContext context,
|
||||
IDomainEvent<CompanyAggregate, CompanyId, CompanyCreatedEvent> domainEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var e = domainEvent.AggregateEvent;
|
||||
AggregateId = domainEvent.AggregateIdentity.Value;
|
||||
CreateTime = domainEvent.Timestamp;
|
||||
UpdatedTime = domainEvent.Timestamp;
|
||||
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
|
||||
Name = e.Name;
|
||||
Cvr = e.Cvr;
|
||||
Address = e.Address;
|
||||
PostalCode = e.PostalCode;
|
||||
City = e.City;
|
||||
Country = e.Country;
|
||||
FiscalYearStartMonth = e.FiscalYearStartMonth;
|
||||
Currency = e.Currency;
|
||||
VatRegistered = e.VatRegistered;
|
||||
VatPeriodFrequency = e.VatPeriodFrequency;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ApplyAsync(
|
||||
IReadModelContext context,
|
||||
IDomainEvent<CompanyAggregate, CompanyId, CompanyUpdatedEvent> domainEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var e = domainEvent.AggregateEvent;
|
||||
UpdatedTime = domainEvent.Timestamp;
|
||||
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
|
||||
Name = e.Name;
|
||||
Cvr = e.Cvr;
|
||||
Address = e.Address;
|
||||
PostalCode = e.PostalCode;
|
||||
City = e.City;
|
||||
Country = e.Country;
|
||||
FiscalYearStartMonth = e.FiscalYearStartMonth;
|
||||
Currency = e.Currency;
|
||||
VatRegistered = e.VatRegistered;
|
||||
VatPeriodFrequency = e.VatPeriodFrequency;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
namespace Books.Api.EventFlow.ReadModels;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for reading company data from the database.
|
||||
/// Uses a class with properties instead of a positional record because
|
||||
/// PostgreSQL returns column names in lowercase, and Dapper matches
|
||||
/// properties case-insensitively but requires exact constructor parameter names.
|
||||
/// </summary>
|
||||
public class CompanyReadModelDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Cvr { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string Country { get; set; } = string.Empty;
|
||||
public int FiscalYearStartMonth { get; set; }
|
||||
public string Currency { get; set; } = string.Empty;
|
||||
public bool VatRegistered { get; set; }
|
||||
public string? VatPeriodFrequency { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using Books.Api.Domain.Companies;
|
||||
using EventFlow.Aggregates;
|
||||
using EventFlow.ReadStores;
|
||||
|
||||
namespace Books.Api.EventFlow.ReadModels;
|
||||
|
||||
public class CompanyReadModelLocator : IReadModelLocator
|
||||
{
|
||||
public IEnumerable<string> GetReadModelIds(IDomainEvent domainEvent)
|
||||
{
|
||||
if (domainEvent is IDomainEvent<CompanyAggregate, CompanyId> typedEvent)
|
||||
{
|
||||
yield return typedEvent.AggregateIdentity.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
using Books.Api.Domain.Companies;
|
||||
using Books.Api.EventFlow.ReadModels;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Books.Api.EventFlow.Repositories;
|
||||
|
||||
public class CompanyRepository(NpgsqlDataSource dataSource) : ICompanyRepository
|
||||
{
|
||||
public async Task<CompanyReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
aggregate_id AS Id,
|
||||
name AS Name,
|
||||
cvr AS Cvr,
|
||||
address AS Address,
|
||||
postal_code AS PostalCode,
|
||||
city AS City,
|
||||
country AS Country,
|
||||
fiscal_year_start_month AS FiscalYearStartMonth,
|
||||
currency AS Currency,
|
||||
vat_registered AS VatRegistered,
|
||||
vat_period_frequency AS VatPeriodFrequency,
|
||||
create_time AS CreatedAt,
|
||||
updated_time AS UpdatedAt
|
||||
FROM company_read_models
|
||||
WHERE aggregate_id = @Id
|
||||
""";
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<CompanyReadModelDto>(sql, new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CompanyReadModelDto>> GetByIds(List<CompanyId> ids,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
aggregate_id AS Id,
|
||||
name AS Name,
|
||||
cvr AS Cvr,
|
||||
address AS Address,
|
||||
postal_code AS PostalCode,
|
||||
city AS City,
|
||||
country AS Country,
|
||||
fiscal_year_start_month AS FiscalYearStartMonth,
|
||||
currency AS Currency,
|
||||
vat_registered AS VatRegistered,
|
||||
vat_period_frequency AS VatPeriodFrequency,
|
||||
create_time AS CreatedAt,
|
||||
updated_time AS UpdatedAt
|
||||
FROM company_read_models
|
||||
WHERE aggregate_id = ANY(@Ids)
|
||||
""";
|
||||
|
||||
return await connection.QueryAsync<CompanyReadModelDto>(sql, new { Ids = ids.Select(i => i.Value).ToArray() });
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CompanyReadModelDto>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
aggregate_id AS Id,
|
||||
name AS Name,
|
||||
cvr AS Cvr,
|
||||
address AS Address,
|
||||
postal_code AS PostalCode,
|
||||
city AS City,
|
||||
country AS Country,
|
||||
fiscal_year_start_month AS FiscalYearStartMonth,
|
||||
currency AS Currency,
|
||||
vat_registered AS VatRegistered,
|
||||
vat_period_frequency AS VatPeriodFrequency,
|
||||
create_time AS CreatedAt,
|
||||
updated_time AS UpdatedAt
|
||||
FROM company_read_models
|
||||
ORDER BY name
|
||||
""";
|
||||
|
||||
var result = await connection.QueryAsync<CompanyReadModelDto>(sql);
|
||||
return result.ToList();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using Books.Api.Domain.Companies;
|
||||
using Books.Api.EventFlow.ReadModels;
|
||||
|
||||
namespace Books.Api.EventFlow.Repositories;
|
||||
|
||||
public interface ICompanyRepository
|
||||
{
|
||||
Task<CompanyReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<CompanyReadModelDto>> GetByIds(List<CompanyId> ids, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<CompanyReadModelDto>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
14
backend/Books.Api/GraphQL/BooksSchema.cs
Normal file
14
backend/Books.Api/GraphQL/BooksSchema.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using Books.Api.GraphQL.Mutations;
|
||||
using Books.Api.GraphQL.Queries;
|
||||
using GraphQL.Types;
|
||||
|
||||
namespace Books.Api.GraphQL;
|
||||
|
||||
public class BooksSchema : Schema
|
||||
{
|
||||
public BooksSchema(IServiceProvider serviceProvider) : base(serviceProvider)
|
||||
{
|
||||
Query = serviceProvider.GetRequiredService<BooksQuery>();
|
||||
Mutation = serviceProvider.GetRequiredService<BooksMutation>();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
using GraphQL.Types;
|
||||
|
||||
namespace Books.Api.GraphQL.InputTypes;
|
||||
|
||||
public class CreateCompanyInputType : InputObjectGraphType<CreateCompanyInput>
|
||||
{
|
||||
public CreateCompanyInputType()
|
||||
{
|
||||
Name = "CreateCompanyInput";
|
||||
Description = "Input for creating a new company";
|
||||
|
||||
Field(x => x.Name).Description("Company name (required)");
|
||||
Field(x => x.Cvr, nullable: true).Description("Danish CVR number");
|
||||
Field(x => x.Address, nullable: true).Description("Street address");
|
||||
Field(x => x.PostalCode, nullable: true).Description("Postal code");
|
||||
Field(x => x.City, nullable: true).Description("City");
|
||||
Field(x => x.Country, nullable: true).Description("Country code (default: DK)");
|
||||
Field(x => x.FiscalYearStartMonth, nullable: true).Description("Month when fiscal year starts (default: 1)");
|
||||
Field(x => x.Currency, nullable: true).Description("Default currency (default: DKK)");
|
||||
Field(x => x.VatRegistered, nullable: true).Description("Whether VAT registered (default: false)");
|
||||
Field(x => x.VatPeriodFrequency, nullable: true).Description("VAT reporting frequency");
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCompanyInput
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Cvr { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public int? FiscalYearStartMonth { get; set; }
|
||||
public string? Currency { get; set; }
|
||||
public bool? VatRegistered { get; set; }
|
||||
public string? VatPeriodFrequency { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
using GraphQL.Types;
|
||||
|
||||
namespace Books.Api.GraphQL.InputTypes;
|
||||
|
||||
public class UpdateCompanyInputType : InputObjectGraphType<UpdateCompanyInput>
|
||||
{
|
||||
public UpdateCompanyInputType()
|
||||
{
|
||||
Name = "UpdateCompanyInput";
|
||||
Description = "Input for updating an existing company";
|
||||
|
||||
Field(x => x.Name).Description("Company name (required)");
|
||||
Field(x => x.Cvr, nullable: true).Description("Danish CVR number");
|
||||
Field(x => x.Address, nullable: true).Description("Street address");
|
||||
Field(x => x.PostalCode, nullable: true).Description("Postal code");
|
||||
Field(x => x.City, nullable: true).Description("City");
|
||||
Field(x => x.Country, nullable: true).Description("Country code");
|
||||
Field(x => x.FiscalYearStartMonth, nullable: true).Description("Month when fiscal year starts");
|
||||
Field(x => x.Currency, nullable: true).Description("Default currency");
|
||||
Field(x => x.VatRegistered, nullable: true).Description("Whether VAT registered");
|
||||
Field(x => x.VatPeriodFrequency, nullable: true).Description("VAT reporting frequency");
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateCompanyInput
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Cvr { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public int? FiscalYearStartMonth { get; set; }
|
||||
public string? Currency { get; set; }
|
||||
public bool? VatRegistered { get; set; }
|
||||
public string? VatPeriodFrequency { get; set; }
|
||||
}
|
||||
82
backend/Books.Api/GraphQL/Mutations/BooksMutation.cs
Normal file
82
backend/Books.Api/GraphQL/Mutations/BooksMutation.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using Books.Api.Commands.Companies;
|
||||
using Books.Api.Domain.Companies;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.GraphQL.InputTypes;
|
||||
using Books.Api.GraphQL.Types;
|
||||
using EventFlow;
|
||||
using GraphQL;
|
||||
using GraphQL.Types;
|
||||
|
||||
namespace Books.Api.GraphQL.Mutations;
|
||||
|
||||
public class BooksMutation : ObjectGraphType
|
||||
{
|
||||
public BooksMutation()
|
||||
{
|
||||
Name = "Mutation";
|
||||
Description = "Root mutation for the Books API";
|
||||
|
||||
// createCompany(input: CreateCompanyInput!): CompanyType
|
||||
Field<CompanyType>("createCompany")
|
||||
.Description("Create a new company")
|
||||
.Argument<NonNullGraphType<CreateCompanyInputType>>("input", "The company data")
|
||||
.ResolveAsync(async ctx =>
|
||||
{
|
||||
var input = ctx.GetArgument<CreateCompanyInput>("input");
|
||||
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
|
||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||
|
||||
var companyId = CompanyId.New;
|
||||
|
||||
var command = new CreateCompanyCommand(
|
||||
companyId,
|
||||
input.Name,
|
||||
input.Cvr,
|
||||
input.Address,
|
||||
input.PostalCode,
|
||||
input.City,
|
||||
input.Country ?? "DK",
|
||||
input.FiscalYearStartMonth ?? 1,
|
||||
input.Currency ?? "DKK",
|
||||
input.VatRegistered ?? false,
|
||||
input.VatPeriodFrequency);
|
||||
|
||||
await commandBus.PublishAsync(command, ctx.CancellationToken);
|
||||
|
||||
// Return the created company (eventually consistent)
|
||||
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
|
||||
});
|
||||
|
||||
// updateCompany(id: ID!, input: UpdateCompanyInput!): CompanyType
|
||||
Field<CompanyType>("updateCompany")
|
||||
.Description("Update an existing company")
|
||||
.Argument<NonNullGraphType<IdGraphType>>("id", "The company ID")
|
||||
.Argument<NonNullGraphType<UpdateCompanyInputType>>("input", "The updated company data")
|
||||
.ResolveAsync(async ctx =>
|
||||
{
|
||||
var id = ctx.GetArgument<string>("id");
|
||||
var input = ctx.GetArgument<UpdateCompanyInput>("input");
|
||||
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
|
||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||
|
||||
var companyId = CompanyId.With(id);
|
||||
|
||||
var command = new UpdateCompanyCommand(
|
||||
companyId,
|
||||
input.Name,
|
||||
input.Cvr,
|
||||
input.Address,
|
||||
input.PostalCode,
|
||||
input.City,
|
||||
input.Country ?? "DK",
|
||||
input.FiscalYearStartMonth ?? 1,
|
||||
input.Currency ?? "DKK",
|
||||
input.VatRegistered ?? false,
|
||||
input.VatPeriodFrequency);
|
||||
|
||||
await commandBus.PublishAsync(command, ctx.CancellationToken);
|
||||
|
||||
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
|
||||
});
|
||||
}
|
||||
}
|
||||
37
backend/Books.Api/GraphQL/Queries/BooksQuery.cs
Normal file
37
backend/Books.Api/GraphQL/Queries/BooksQuery.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using Books.Api.Domain.Companies;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.GraphQL.Types;
|
||||
using GraphQL;
|
||||
using GraphQL.Types;
|
||||
|
||||
namespace Books.Api.GraphQL.Queries;
|
||||
|
||||
public class BooksQuery : ObjectGraphType
|
||||
{
|
||||
public BooksQuery()
|
||||
{
|
||||
Name = "Query";
|
||||
Description = "Root query for the Books API";
|
||||
|
||||
// companies: [CompanyType]
|
||||
Field<ListGraphType<CompanyType>>("companies")
|
||||
.Description("Get all companies")
|
||||
.ResolveAsync(async ctx =>
|
||||
{
|
||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||
return await repository.GetAllAsync(ctx.CancellationToken);
|
||||
});
|
||||
|
||||
// company(id: ID!): CompanyType
|
||||
Field<CompanyType>("company")
|
||||
.Description("Get a company by ID")
|
||||
.Argument<NonNullGraphType<IdGraphType>>("id", "The company ID")
|
||||
.ResolveAsync(async ctx =>
|
||||
{
|
||||
var id = ctx.GetArgument<string>("id");
|
||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||
var companies = await repository.GetByIds([CompanyId.With(id)], ctx.CancellationToken);
|
||||
return companies.FirstOrDefault();
|
||||
});
|
||||
}
|
||||
}
|
||||
31
backend/Books.Api/GraphQL/Types/CompanyType.cs
Normal file
31
backend/Books.Api/GraphQL/Types/CompanyType.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using Books.Api.EventFlow.ReadModels;
|
||||
using GraphQL.Types;
|
||||
|
||||
namespace Books.Api.GraphQL.Types;
|
||||
|
||||
public class CompanyType : ObjectGraphType<CompanyReadModelDto>
|
||||
{
|
||||
public CompanyType()
|
||||
{
|
||||
Name = "Company";
|
||||
Description = "A company/organization for bookkeeping";
|
||||
|
||||
Field(x => x.Id).Description("Unique identifier");
|
||||
Field(x => x.Name).Description("Company name");
|
||||
Field(x => x.Cvr, nullable: true).Description("Danish CVR number");
|
||||
Field(x => x.Address, nullable: true).Description("Street address");
|
||||
Field(x => x.PostalCode, nullable: true).Description("Postal code");
|
||||
Field(x => x.City, nullable: true).Description("City");
|
||||
Field(x => x.Country).Description("Country code (e.g., DK)");
|
||||
Field(x => x.FiscalYearStartMonth).Description("Month when fiscal year starts (1-12)");
|
||||
Field(x => x.Currency).Description("Default currency (e.g., DKK)");
|
||||
Field(x => x.VatRegistered).Description("Whether the company is VAT registered");
|
||||
Field(x => x.VatPeriodFrequency, nullable: true).Description("VAT reporting frequency");
|
||||
Field(x => x.CreatedAt).Description("When the company was created");
|
||||
Field(x => x.UpdatedAt).Description("When the company was last updated");
|
||||
|
||||
// Nested fields will be added later:
|
||||
// - fiscalYears: [FiscalYearType] via DataLoader
|
||||
// - accounts: [AccountType] via DataLoader
|
||||
}
|
||||
}
|
||||
27
backend/Books.Api/Infrastructure/HangfireScheduler.cs
Normal file
27
backend/Books.Api/Infrastructure/HangfireScheduler.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.Linq.Expressions;
|
||||
using Hangfire;
|
||||
|
||||
namespace Books.Api.Infrastructure;
|
||||
|
||||
public class HangfireScheduler(IBackgroundJobClient jobClient, IRecurringJobManager recurringJobManager) : IScheduler
|
||||
{
|
||||
public void EnqueueJob<T>(Expression<Func<T, Task>> methodCall)
|
||||
{
|
||||
jobClient.Enqueue(methodCall);
|
||||
}
|
||||
|
||||
public void EnqueueJob<T>(Expression<Func<T, Task>> methodCall, TimeSpan delay)
|
||||
{
|
||||
jobClient.Schedule(methodCall, delay);
|
||||
}
|
||||
|
||||
public void EnqueueJob(Expression<Func<Task>> methodCall)
|
||||
{
|
||||
jobClient.Enqueue(methodCall);
|
||||
}
|
||||
|
||||
public void AddOrUpdateScheduledJob<T>(string title, Expression<Func<T?, Task>> methodCall, string cron)
|
||||
{
|
||||
recurringJobManager.AddOrUpdate(title, methodCall, cron);
|
||||
}
|
||||
}
|
||||
11
backend/Books.Api/Infrastructure/IScheduler.cs
Normal file
11
backend/Books.Api/Infrastructure/IScheduler.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using System.Linq.Expressions;
|
||||
|
||||
namespace Books.Api.Infrastructure;
|
||||
|
||||
public interface IScheduler
|
||||
{
|
||||
void EnqueueJob<T>(Expression<Func<T, Task>> methodCall);
|
||||
void EnqueueJob<T>(Expression<Func<T, Task>> methodCall, TimeSpan delay);
|
||||
void EnqueueJob(Expression<Func<Task>> methodCall);
|
||||
void AddOrUpdateScheduledJob<T>(string title, Expression<Func<T?, Task>> methodCall, string cron);
|
||||
}
|
||||
13
backend/Books.Api/Logging/ServiceCollectionExtensions.cs
Normal file
13
backend/Books.Api/Logging/ServiceCollectionExtensions.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace Books.Api.Logging;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection DecorateAsyncEventHandlersWithLogging(this IServiceCollection services)
|
||||
{
|
||||
// Decoration will be set up once we have event handlers registered
|
||||
// The SubscribeAsynchronousToDecorator wraps handlers with logging
|
||||
// This is a placeholder for now - implement when we have actual event handlers
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
using System.Diagnostics;
|
||||
using EventFlow.Aggregates;
|
||||
using EventFlow.Core;
|
||||
using EventFlow.Subscribers;
|
||||
|
||||
namespace Books.Api.Logging;
|
||||
|
||||
public class SubscribeAsynchronousToDecorator<TAggregate, TIdentity, TEvent>(
|
||||
ISubscribeAsynchronousTo<TAggregate, TIdentity, TEvent> inner,
|
||||
ILogger<SubscribeAsynchronousToDecorator<TAggregate, TIdentity, TEvent>> logger)
|
||||
: ISubscribeAsynchronousTo<TAggregate, TIdentity, TEvent>
|
||||
where TAggregate : IAggregateRoot<TIdentity>
|
||||
where TIdentity : IIdentity
|
||||
where TEvent : IAggregateEvent<TAggregate, TIdentity>
|
||||
{
|
||||
public async Task HandleAsync(
|
||||
IDomainEvent<TAggregate, TIdentity, TEvent> domainEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var eventName = typeof(TEvent).Name;
|
||||
var handlerName = inner.GetType().Name;
|
||||
var aggregateId = domainEvent.AggregateIdentity.Value;
|
||||
|
||||
logger.LogDebug(
|
||||
"Handling {EventName} for {AggregateId} with {HandlerName}",
|
||||
eventName, aggregateId, handlerName);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
await inner.HandleAsync(domainEvent, cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
logger.LogInformation(
|
||||
"Handled {EventName} for {AggregateId} with {HandlerName} in {ElapsedMs}ms",
|
||||
eventName, aggregateId, handlerName, stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
logger.LogError(ex,
|
||||
"Failed to handle {EventName} for {AggregateId} with {HandlerName} after {ElapsedMs}ms",
|
||||
eventName, aggregateId, handlerName, stopwatch.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
backend/Books.Api/Program.cs
Normal file
35
backend/Books.Api/Program.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using Books.Api;
|
||||
using Books.Api.GraphQL;
|
||||
using GraphQL;
|
||||
using GraphQL.Server.Ui.Altair;
|
||||
using Hangfire;
|
||||
|
||||
// Enable legacy timestamp behavior for Npgsql 6.0+
|
||||
// This allows DateTimeOffset with non-UTC offsets to be written to timestamptz columns
|
||||
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure EventFlow, Hangfire, GraphQL and all services
|
||||
Startup.ConfigureServices(builder.Services, builder.Configuration, builder.Environment);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHangfireDashboard();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// GraphQL endpoint
|
||||
app.UseGraphQL<BooksSchema>("/graphql");
|
||||
|
||||
// GraphQL UI (development only) - use external tools like Altair, Insomnia, or GraphiQL
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseGraphQLAltair("/graphql/ui");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
23
backend/Books.Api/Properties/launchSettings.json
Normal file
23
backend/Books.Api/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5142",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7141;http://localhost:5142",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
backend/Books.Api/Startup.cs
Normal file
77
backend/Books.Api/Startup.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using Books.Api.EventFlow.Extensions;
|
||||
using Books.Api.EventFlow.Infrastructure;
|
||||
using Books.Api.GraphQL;
|
||||
using Books.Api.Infrastructure;
|
||||
using Books.Api.Logging;
|
||||
using EventFlow;
|
||||
using EventFlow.Configuration;
|
||||
using EventFlow.Extensions;
|
||||
using EventFlow.Hangfire.Extensions;
|
||||
using EventFlow.PostgreSql.Connections;
|
||||
using EventFlow.PostgreSql.Extensions;
|
||||
using EventFlow.ReadStores;
|
||||
using EventFlow.Subscribers;
|
||||
using GraphQL;
|
||||
using Hangfire;
|
||||
using Hangfire.PostgreSql;
|
||||
using Npgsql;
|
||||
|
||||
namespace Books.Api;
|
||||
|
||||
public static class Startup
|
||||
{
|
||||
public static void ConfigureServices(IServiceCollection services, IConfiguration config, IHostEnvironment? environment = null)
|
||||
{
|
||||
var connectionString = config.GetConnectionString("Default")
|
||||
?? throw new InvalidOperationException("Connection string 'Default' not found");
|
||||
|
||||
// Run database migrations (skipped in Test environment where migrations run separately with test connection string)
|
||||
var isTestEnvironment = environment?.EnvironmentName == "Test";
|
||||
if (!isTestEnvironment)
|
||||
{
|
||||
DatabaseMigrator.Migrate(connectionString);
|
||||
}
|
||||
|
||||
// PostgreSQL data source
|
||||
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
services.AddSingleton(dataSource);
|
||||
|
||||
// Hangfire
|
||||
services.AddHangfire(c => c
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UsePostgreSqlStorage(o => o.UseNpgsqlConnection(connectionString)));
|
||||
services.AddHangfireServer();
|
||||
|
||||
// Scheduler abstraction over Hangfire
|
||||
services.AddSingleton<IScheduler, HangfireScheduler>();
|
||||
|
||||
// EventFlow
|
||||
services.AddEventFlow(o => o
|
||||
.UsePostgreSqlEventStore()
|
||||
.ConfigurePostgreSql(PostgreSqlConfiguration.New.SetConnectionString(connectionString))
|
||||
.AddDefaults(typeof(Startup).Assembly)
|
||||
.AddReadModels()
|
||||
.Configure(c => c.IsAsynchronousSubscribersEnabled = true)
|
||||
.UseHangfireJobScheduler());
|
||||
|
||||
// Resilience strategies
|
||||
services.AddSingleton<IDispatchToReadStoresResilienceStrategy, ReadStoresResilienceStrategy>();
|
||||
services.AddSingleton<IDispatchToSubscriberResilienceStrategy, DispatchToSubscriberResilienceStrategy>();
|
||||
|
||||
// Read model repositories
|
||||
services.AddRepositories();
|
||||
|
||||
// Logging decorators
|
||||
services.DecorateAsyncEventHandlersWithLogging();
|
||||
|
||||
// GraphQL
|
||||
services.AddGraphQL(builder => builder
|
||||
.AddSchema<BooksSchema>()
|
||||
.AddSystemTextJson()
|
||||
.AddDataLoader()
|
||||
.AddGraphTypes(typeof(BooksSchema).Assembly)
|
||||
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
|
||||
}
|
||||
}
|
||||
12
backend/Books.Api/WeatherForecast.cs
Normal file
12
backend/Books.Api/WeatherForecast.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace Books.Api;
|
||||
|
||||
public class WeatherForecast
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
|
||||
public int TemperatureC { get; set; }
|
||||
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
14
backend/Books.Api/appsettings.json
Normal file
14
backend/Books.Api/appsettings.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"EventFlow": "Information",
|
||||
"Hangfire": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Default": "Host=localhost;Database=books;Username=postgres;Password=postgres;Include Error Detail=true"
|
||||
}
|
||||
}
|
||||
11
backend/Books.slnx
Normal file
11
backend/Books.slnx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Solution>
|
||||
<Project Path="..\..\enable-banking-access\src\EnableBanking.Client\EnableBanking.Client.csproj" Type="Classic C#" />
|
||||
<Project Path="..\..\enable-banking-access\src\EnableBanking.Core\EnableBanking.Core.csproj" Type="Classic C#" />
|
||||
<Project Path="..\..\enable-banking-access\src\EnableBanking.Persistence.Postgres\EnableBanking.Persistence.Postgres.csproj" Type="Classic C#" />
|
||||
<Project Path="..\..\enable-banking-access\tests\EnableBanking.Client.Tests\EnableBanking.Client.Tests.csproj" Type="Classic C#" />
|
||||
<Project Path="..\..\enable-banking-access\tests\EnableBanking.Core.Tests\EnableBanking.Core.Tests.csproj" Type="Classic C#" />
|
||||
<Project Path="..\..\ledger\src\Ledger.Core\Ledger.Core.csproj" Type="Classic C#" />
|
||||
<Project Path="..\..\ledger\src\Ledger.Infrastructure\Ledger.Infrastructure.csproj" Type="Classic C#" />
|
||||
<Project Path="Books.Api.Tests\Books.Api.Tests.csproj" Type="Classic C#" />
|
||||
<Project Path="Books.Api/Books.Api.csproj" />
|
||||
</Solution>
|
||||
Loading…
Add table
Add a link
Reference in a new issue