Skip to content

Commit 8add71c

Browse files
authored
Merge pull request #3 from sl-cloud/develop
Develop -> Main
2 parents 1763d35 + 8f7d70a commit 8add71c

File tree

18 files changed

+2621
-15
lines changed

18 files changed

+2621
-15
lines changed

Directory.Packages.props

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<!-- Application Layer -->
88
<PackageVersion Include="MediatR" Version="12.4.1" />
99
<PackageVersion Include="FluentValidation" Version="11.10.0" />
10+
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
1011

1112
<!-- Infrastructure Layer - EF Core -->
1213
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
@@ -22,8 +23,9 @@
2223
<PackageVersion Include="Amazon.Lambda.SQSEvents" Version="2.2.0" />
2324
<PackageVersion Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.4.3" />
2425

25-
<!-- Dependency Injection -->
26+
<!-- Dependency Injection & Logging -->
2627
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
28+
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
2729

2830
<!-- Testing Frameworks -->
2931
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,6 @@ See [`.cursor/rules/ai-agent.mdc`](.cursor/rules/ai-agent.mdc) for complete codi
195195

196196
## Next Steps
197197

198-
### Phase 2: Domain Layer
199-
- Implement `Lead` entity with validation rules
200-
- Create `ILeadRepository` interface
201-
202198
### Phase 3: Application Layer
203199
- Create DTOs and MediatR commands
204200
- Implement FluentValidation validators
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using MediatR;
2+
3+
namespace LeadProcessor.Application.Commands;
4+
5+
/// <summary>
6+
/// Command to process a lead received from the SQS queue.
7+
/// Implements MediatR's IRequest interface for CQRS pattern.
8+
/// </summary>
9+
public record ProcessLeadCommand : IRequest<Unit>
10+
{
11+
/// <summary>
12+
/// Gets the tenant identifier for multi-tenancy support.
13+
/// </summary>
14+
public required string TenantId { get; init; }
15+
16+
/// <summary>
17+
/// Gets the correlation identifier for idempotency and message tracking.
18+
/// </summary>
19+
public required string CorrelationId { get; init; }
20+
21+
/// <summary>
22+
/// Gets the email address of the lead.
23+
/// </summary>
24+
public required string Email { get; init; }
25+
26+
/// <summary>
27+
/// Gets the first name of the lead.
28+
/// </summary>
29+
public string? FirstName { get; init; }
30+
31+
/// <summary>
32+
/// Gets the last name of the lead.
33+
/// </summary>
34+
public string? LastName { get; init; }
35+
36+
/// <summary>
37+
/// Gets the phone number of the lead.
38+
/// </summary>
39+
public string? Phone { get; init; }
40+
41+
/// <summary>
42+
/// Gets the company name of the lead.
43+
/// </summary>
44+
public string? Company { get; init; }
45+
46+
/// <summary>
47+
/// Gets the source from which the lead originated.
48+
/// </summary>
49+
public required string Source { get; init; }
50+
51+
/// <summary>
52+
/// Gets the metadata as a JSON string containing additional information.
53+
/// </summary>
54+
public string? Metadata { get; init; }
55+
56+
/// <summary>
57+
/// Gets the ISO 8601 formatted timestamp when the message was sent.
58+
/// Used for message tracking and debugging.
59+
/// </summary>
60+
public string? MessageTimestamp { get; init; }
61+
}
62+
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
namespace LeadProcessor.Application.DTOs;
2+
3+
/// <summary>
4+
/// Represents a lead creation event received from the SQS message queue.
5+
/// This DTO maps to the message structure sent by the PHP gateway.
6+
/// </summary>
7+
public record LeadCreatedEvent
8+
{
9+
/// <summary>
10+
/// Gets the tenant identifier for multi-tenancy support.
11+
/// </summary>
12+
public required string TenantId { get; init; }
13+
14+
/// <summary>
15+
/// Gets the correlation identifier for idempotency and message tracking.
16+
/// Must be unique per message to prevent duplicate processing.
17+
/// </summary>
18+
public required string CorrelationId { get; init; }
19+
20+
/// <summary>
21+
/// Gets the email address of the lead.
22+
/// </summary>
23+
public required string Email { get; init; }
24+
25+
/// <summary>
26+
/// Gets the first name of the lead.
27+
/// </summary>
28+
public string? FirstName { get; init; }
29+
30+
/// <summary>
31+
/// Gets the last name of the lead.
32+
/// </summary>
33+
public string? LastName { get; init; }
34+
35+
/// <summary>
36+
/// Gets the phone number of the lead.
37+
/// </summary>
38+
public string? Phone { get; init; }
39+
40+
/// <summary>
41+
/// Gets the company name of the lead.
42+
/// </summary>
43+
public string? Company { get; init; }
44+
45+
/// <summary>
46+
/// Gets the source from which the lead originated (e.g., website, mobile app, referral).
47+
/// </summary>
48+
public required string Source { get; init; }
49+
50+
/// <summary>
51+
/// Gets the metadata as a JSON string containing additional information about the lead.
52+
/// </summary>
53+
public string? Metadata { get; init; }
54+
55+
/// <summary>
56+
/// Gets the ISO 8601 formatted timestamp when the message was sent.
57+
/// This will be parsed to DateTimeOffset in the handler.
58+
/// </summary>
59+
public string? MessageTimestamp { get; init; }
60+
}
61+
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using FluentValidation;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using System.Reflection;
4+
5+
namespace LeadProcessor.Application.DependencyInjection;
6+
7+
/// <summary>
8+
/// Extension methods for configuring application services in the dependency injection container.
9+
/// </summary>
10+
public static class ServiceCollectionExtensions
11+
{
12+
/// <summary>
13+
/// Adds Application layer services to the dependency injection container.
14+
/// This includes MediatR, FluentValidation, and all command handlers and validators.
15+
/// </summary>
16+
/// <param name="services">The service collection to add services to.</param>
17+
/// <returns>The service collection for method chaining.</returns>
18+
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
19+
{
20+
var assembly = Assembly.GetExecutingAssembly();
21+
22+
// Register MediatR with all handlers from this assembly
23+
services.AddMediatR(config =>
24+
{
25+
config.RegisterServicesFromAssembly(assembly);
26+
});
27+
28+
// Register FluentValidation validators from this assembly
29+
services.AddValidatorsFromAssembly(assembly);
30+
31+
return services;
32+
}
33+
}
34+
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
using FluentValidation;
2+
using LeadProcessor.Application.Commands;
3+
using LeadProcessor.Domain.Entities;
4+
using LeadProcessor.Domain.Exceptions;
5+
using LeadProcessor.Domain.Repositories;
6+
using LeadProcessor.Domain.Services;
7+
using MediatR;
8+
using Microsoft.Extensions.Logging;
9+
using System.Globalization;
10+
11+
namespace LeadProcessor.Application.Handlers;
12+
13+
/// <summary>
14+
/// Handler for processing lead commands.
15+
/// Implements idempotency, validation, and persistence logic for incoming leads.
16+
/// </summary>
17+
public class ProcessLeadCommandHandler(
18+
ILeadRepository repository,
19+
IDateTimeProvider dateTimeProvider,
20+
IValidator<ProcessLeadCommand> validator,
21+
ILogger<ProcessLeadCommandHandler> logger) : IRequestHandler<ProcessLeadCommand, Unit>
22+
{
23+
/// <summary>
24+
/// Handles the ProcessLeadCommand by validating, checking for duplicates, and persisting the lead.
25+
/// </summary>
26+
/// <param name="request">The command containing lead data.</param>
27+
/// <param name="cancellationToken">Cancellation token to cancel the operation.</param>
28+
/// <returns>Unit value indicating successful completion.</returns>
29+
/// <exception cref="ValidationException">Thrown when the command fails validation.</exception>
30+
/// <exception cref="DuplicateLeadException">Thrown when a lead with the same correlation ID already exists.</exception>
31+
public async Task<Unit> Handle(ProcessLeadCommand request, CancellationToken cancellationToken)
32+
{
33+
logger.LogInformation(
34+
"Processing lead command for correlation ID {CorrelationId}, tenant {TenantId}, email {Email}",
35+
request.CorrelationId,
36+
request.TenantId,
37+
request.Email);
38+
39+
try
40+
{
41+
// 1. Validate the command
42+
var validationResult = await validator.ValidateAsync(request, cancellationToken);
43+
if (!validationResult.IsValid)
44+
{
45+
logger.LogWarning(
46+
"Validation failed for correlation ID {CorrelationId}: {Errors}",
47+
request.CorrelationId,
48+
string.Join(", ", validationResult.Errors.Select(e => e.ErrorMessage)));
49+
50+
throw new ValidationException(validationResult.Errors);
51+
}
52+
53+
// 2. Check for idempotency - prevent duplicate processing
54+
var exists = await repository.ExistsByCorrelationIdAsync(request.CorrelationId, cancellationToken);
55+
if (exists)
56+
{
57+
logger.LogInformation(
58+
"Lead with correlation ID {CorrelationId} already exists. Skipping duplicate processing.",
59+
request.CorrelationId);
60+
61+
throw new DuplicateLeadException(request.CorrelationId);
62+
}
63+
64+
// 3. Get current timestamp for entity creation
65+
var now = dateTimeProvider.UtcNow;
66+
67+
// 4. Parse message timestamp for future audit trail enhancement
68+
DateTimeOffset? messageTimestamp = null;
69+
if (!string.IsNullOrWhiteSpace(request.MessageTimestamp))
70+
{
71+
if (DateTimeOffset.TryParse(
72+
request.MessageTimestamp,
73+
CultureInfo.InvariantCulture,
74+
DateTimeStyles.AssumeUniversal,
75+
out var parsedTimestamp))
76+
{
77+
messageTimestamp = parsedTimestamp;
78+
logger.LogDebug(
79+
"Parsed message timestamp {MessageTimestamp} for correlation ID {CorrelationId}",
80+
messageTimestamp,
81+
request.CorrelationId);
82+
}
83+
else
84+
{
85+
logger.LogWarning(
86+
"Failed to parse message timestamp '{MessageTimestamp}' for correlation ID {CorrelationId}",
87+
request.MessageTimestamp,
88+
request.CorrelationId);
89+
}
90+
}
91+
92+
// 5. Map command to domain entity
93+
var lead = new Lead
94+
{
95+
TenantId = request.TenantId,
96+
CorrelationId = request.CorrelationId,
97+
Email = request.Email,
98+
FirstName = request.FirstName,
99+
LastName = request.LastName,
100+
Phone = request.Phone,
101+
Company = request.Company,
102+
Source = request.Source,
103+
Metadata = request.Metadata,
104+
CreatedAt = now,
105+
UpdatedAt = now
106+
};
107+
108+
// 6. Persist to repository
109+
var savedLead = await repository.SaveLeadAsync(lead, cancellationToken);
110+
111+
logger.LogInformation(
112+
"Successfully processed lead {LeadId} for correlation ID {CorrelationId}, tenant {TenantId}",
113+
savedLead.Id,
114+
savedLead.CorrelationId,
115+
savedLead.TenantId);
116+
117+
return Unit.Value;
118+
}
119+
catch (ValidationException)
120+
{
121+
// Re-throw validation exceptions without logging as error
122+
// (already logged as warning above)
123+
throw;
124+
}
125+
catch (DuplicateLeadException)
126+
{
127+
// Re-throw duplicate exceptions without logging as error
128+
// (already logged as information above - this is expected behavior)
129+
throw;
130+
}
131+
catch (Exception ex)
132+
{
133+
logger.LogError(
134+
ex,
135+
"Unexpected error processing lead for correlation ID {CorrelationId}, tenant {TenantId}",
136+
request.CorrelationId,
137+
request.TenantId);
138+
throw;
139+
}
140+
}
141+
}
142+

src/LeadProcessor.Application/LeadProcessor.Application.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
<ItemGroup>
1010
<PackageReference Include="FluentValidation" />
11+
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
1112
<PackageReference Include="MediatR" />
13+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
1214
</ItemGroup>
1315

1416
<ItemGroup>

0 commit comments

Comments
 (0)