Compare commits
14
Commits
1931899d53
...
endpoints
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5205f8443 | ||
|
|
5a4c621950 | ||
|
|
be5fbdbb5f | ||
|
|
a3bf7b375b | ||
|
|
7535c60ca6 | ||
|
|
de20ace27d | ||
|
|
9f1146130e | ||
|
|
5a325b0a75 | ||
|
|
ab2ef9da31 | ||
|
|
77517579e3 | ||
|
|
8f7af90344 | ||
|
|
b48c663756 | ||
|
|
e3058e0439 | ||
|
|
25cba4a3d5 |
+287
@@ -0,0 +1,287 @@
|
||||
# Remove the line below if you want to inherit .editorconfig settings from higher directories
|
||||
root = true
|
||||
|
||||
# C# files
|
||||
[*.cs]
|
||||
# IDE0250: Prefer make struct 'readonly'
|
||||
dotnet_diagnostic.IDE0250.severity = warning
|
||||
|
||||
# IDE0060: Remove unused parameters (Good cleanup pairing)
|
||||
dotnet_diagnostic.IDE0060.severity = warning
|
||||
|
||||
# CA1852: Seal internal types (Available in modern .NET)
|
||||
dotnet_diagnostic.CA1852.severity = warning
|
||||
|
||||
# MA0018: Add sealed modifier to types that are never inherited
|
||||
dotnet_diagnostic.MA0018.severity = warning
|
||||
|
||||
# Enforce that classes should be sealed
|
||||
dotnet_diagnostic.MA0053.severity = warning
|
||||
|
||||
# CRITICAL: Force the analyzer to also flag PUBLIC classes, not just internal ones
|
||||
meziantou_analyzer.MA0053.public_class_should_be_sealed = true
|
||||
MA0053.public_class_should_be_sealed = true
|
||||
|
||||
# Keep the rule active as a warning by default
|
||||
dotnet_diagnostic.MA0048.severity = warning
|
||||
|
||||
# Specific exclusions for Meziantou.Analyzer MA0048
|
||||
# Disable the rule for enums
|
||||
meziantou_analyzer.MA0048.exclude_enums = true
|
||||
|
||||
# Disable the rule for records
|
||||
meziantou_analyzer.MA0048.exclude_records = true
|
||||
|
||||
#EXCLUDE specific files that are meant to hold grouped enums/records
|
||||
dotnet_diagnostic.MA0048.severity = warning
|
||||
|
||||
# Disable the requirement to specify ConfigureAwait(false)
|
||||
dotnet_diagnostic.MA0004.severity = none
|
||||
|
||||
# ALTERNATIVE: Exclude any file ending with 'Enums.cs' or 'Records.cs'
|
||||
# (e.g., BillingEnums.cs, CustomerRecords.cs)
|
||||
[**/*{Enums,Records}.cs]
|
||||
dotnet_diagnostic.MA0048.severity = none
|
||||
|
||||
#### Core EditorConfig Options ####
|
||||
|
||||
# Indentation and spacing
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
tab_width = 4
|
||||
|
||||
# New line preferences
|
||||
end_of_line = crlf
|
||||
insert_final_newline = false
|
||||
|
||||
#### .NET Code Actions ####
|
||||
|
||||
# Type members
|
||||
dotnet_hide_advanced_members = false
|
||||
dotnet_member_insertion_location = with_other_members_of_the_same_kind
|
||||
dotnet_property_generation_behavior = prefer_throwing_properties
|
||||
|
||||
# Symbol search
|
||||
dotnet_search_reference_assemblies = true
|
||||
|
||||
#### .NET Coding Conventions ####
|
||||
|
||||
# Organize usings
|
||||
dotnet_separate_import_directive_groups = false
|
||||
dotnet_sort_system_directives_first = false
|
||||
file_header_template = unset
|
||||
|
||||
# this. and Me. preferences
|
||||
dotnet_style_qualification_for_event = false
|
||||
dotnet_style_qualification_for_field = false
|
||||
dotnet_style_qualification_for_method = false
|
||||
dotnet_style_qualification_for_property = false
|
||||
|
||||
# Language keywords vs BCL types preferences
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true
|
||||
dotnet_style_predefined_type_for_member_access = true
|
||||
|
||||
# Parentheses preferences
|
||||
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
|
||||
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
|
||||
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
|
||||
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
|
||||
|
||||
# Modifier preferences
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members
|
||||
|
||||
# Expression-level preferences
|
||||
dotnet_prefer_system_hash_code = true
|
||||
dotnet_style_coalesce_expression = true
|
||||
dotnet_style_collection_initializer = true
|
||||
dotnet_style_explicit_tuple_names = true
|
||||
dotnet_style_namespace_match_folder = true
|
||||
dotnet_style_null_propagation = true
|
||||
dotnet_style_object_initializer = true
|
||||
dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
||||
dotnet_style_prefer_auto_properties = true
|
||||
dotnet_style_prefer_collection_expression = when_types_loosely_match
|
||||
dotnet_style_prefer_compound_assignment = true
|
||||
dotnet_style_prefer_conditional_expression_over_assignment = true
|
||||
dotnet_style_prefer_conditional_expression_over_return = true
|
||||
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
|
||||
dotnet_style_prefer_inferred_anonymous_type_member_names = true
|
||||
dotnet_style_prefer_inferred_tuple_names = true
|
||||
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
|
||||
dotnet_style_prefer_non_hidden_explicit_cast_in_source = true
|
||||
dotnet_style_prefer_simplified_boolean_expressions = true
|
||||
dotnet_style_prefer_simplified_interpolation = true
|
||||
|
||||
# Field preferences
|
||||
dotnet_style_readonly_field = true
|
||||
|
||||
# Parameter preferences
|
||||
dotnet_code_quality_unused_parameters = all
|
||||
|
||||
# Suppression preferences
|
||||
dotnet_remove_unnecessary_suppression_exclusions = none
|
||||
|
||||
# New line preferences
|
||||
dotnet_style_allow_multiple_blank_lines_experimental = true
|
||||
dotnet_style_allow_statement_immediately_after_block_experimental = true
|
||||
|
||||
#### C# Coding Conventions ####
|
||||
|
||||
# var preferences
|
||||
csharp_style_var_elsewhere = false
|
||||
csharp_style_var_for_built_in_types = false
|
||||
csharp_style_var_when_type_is_apparent = false
|
||||
|
||||
# Expression-bodied members
|
||||
csharp_style_expression_bodied_accessors = true
|
||||
csharp_style_expression_bodied_constructors = false
|
||||
csharp_style_expression_bodied_indexers = true
|
||||
csharp_style_expression_bodied_lambdas = true
|
||||
csharp_style_expression_bodied_local_functions = true
|
||||
csharp_style_expression_bodied_methods = false
|
||||
csharp_style_expression_bodied_operators = false
|
||||
csharp_style_expression_bodied_properties = true
|
||||
|
||||
# Pattern matching preferences
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true
|
||||
csharp_style_prefer_extended_property_pattern = true
|
||||
csharp_style_prefer_not_pattern = true
|
||||
csharp_style_prefer_pattern_matching = true
|
||||
csharp_style_prefer_switch_expression = true
|
||||
|
||||
# Null-checking preferences
|
||||
csharp_style_conditional_delegate_call = true
|
||||
|
||||
# Modifier preferences
|
||||
csharp_prefer_static_anonymous_function = true
|
||||
csharp_prefer_static_local_function = true
|
||||
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
|
||||
csharp_style_prefer_readonly_struct = true
|
||||
csharp_style_prefer_readonly_struct_member = true
|
||||
|
||||
# Code-block preferences
|
||||
csharp_prefer_braces = true
|
||||
csharp_prefer_simple_using_statement = true
|
||||
csharp_prefer_system_threading_lock = true
|
||||
csharp_style_namespace_declarations = block_scoped
|
||||
csharp_style_prefer_method_group_conversion = true
|
||||
csharp_style_prefer_primary_constructors = true
|
||||
csharp_style_prefer_simple_property_accessors = true
|
||||
csharp_style_prefer_top_level_statements = true
|
||||
|
||||
# Expression-level preferences
|
||||
csharp_prefer_simple_default_expression = true
|
||||
csharp_style_deconstructed_variable_declaration = true
|
||||
csharp_style_implicit_object_creation_when_type_is_apparent = true
|
||||
csharp_style_inlined_variable_declaration = true
|
||||
csharp_style_prefer_implicitly_typed_lambda_expression = true
|
||||
csharp_style_prefer_index_operator = true
|
||||
csharp_style_prefer_local_over_anonymous_function = true
|
||||
csharp_style_prefer_null_check_over_type_check = true
|
||||
csharp_style_prefer_range_operator = true
|
||||
csharp_style_prefer_tuple_swap = true
|
||||
csharp_style_prefer_unbound_generic_type_in_nameof = true
|
||||
csharp_style_prefer_utf8_string_literals = true
|
||||
csharp_style_throw_expression = true
|
||||
csharp_style_unused_value_assignment_preference = discard_variable
|
||||
csharp_style_unused_value_expression_statement_preference = discard_variable
|
||||
|
||||
# 'using' directive preferences
|
||||
csharp_using_directive_placement = outside_namespace
|
||||
|
||||
# New line preferences
|
||||
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true
|
||||
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true
|
||||
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true
|
||||
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true
|
||||
csharp_style_allow_embedded_statements_on_same_line_experimental = true
|
||||
|
||||
#### C# Formatting Rules ####
|
||||
|
||||
# New line preferences
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_finally = true
|
||||
csharp_new_line_before_members_in_anonymous_types = true
|
||||
csharp_new_line_before_members_in_object_initializers = true
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_between_query_expression_clauses = true
|
||||
|
||||
# Indentation preferences
|
||||
csharp_indent_block_contents = true
|
||||
csharp_indent_braces = false
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_case_contents_when_block = true
|
||||
csharp_indent_labels = one_less_than_current
|
||||
csharp_indent_switch_labels = true
|
||||
|
||||
# Space preferences
|
||||
csharp_space_after_cast = false
|
||||
csharp_space_after_colon_in_inheritance_clause = true
|
||||
csharp_space_after_comma = true
|
||||
csharp_space_after_dot = false
|
||||
csharp_space_after_keywords_in_control_flow_statements = true
|
||||
csharp_space_after_semicolon_in_for_statement = true
|
||||
csharp_space_around_binary_operators = before_and_after
|
||||
csharp_space_around_declaration_statements = false
|
||||
csharp_space_before_colon_in_inheritance_clause = true
|
||||
csharp_space_before_comma = false
|
||||
csharp_space_before_dot = false
|
||||
csharp_space_before_open_square_brackets = false
|
||||
csharp_space_before_semicolon_in_for_statement = false
|
||||
csharp_space_between_empty_square_brackets = false
|
||||
csharp_space_between_method_call_empty_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_name_and_opening_parenthesis = false
|
||||
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
|
||||
csharp_space_between_method_declaration_name_and_open_parenthesis = false
|
||||
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||
csharp_space_between_parentheses = false
|
||||
csharp_space_between_square_brackets = false
|
||||
|
||||
# Wrapping preferences
|
||||
csharp_preserve_single_line_blocks = true
|
||||
csharp_preserve_single_line_statements = true
|
||||
|
||||
#### Naming styles ####
|
||||
|
||||
# Naming rules
|
||||
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
|
||||
|
||||
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
|
||||
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
|
||||
|
||||
# Symbol specifications
|
||||
|
||||
dotnet_naming_symbols.interface.applicable_kinds = interface
|
||||
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.interface.required_modifiers =
|
||||
|
||||
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
|
||||
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.types.required_modifiers =
|
||||
|
||||
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
|
||||
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.non_field_members.required_modifiers =
|
||||
|
||||
# Naming styles
|
||||
|
||||
dotnet_naming_style.pascal_case.required_prefix =
|
||||
dotnet_naming_style.pascal_case.required_suffix =
|
||||
dotnet_naming_style.pascal_case.word_separator =
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.begins_with_i.required_prefix = I
|
||||
dotnet_naming_style.begins_with_i.required_suffix =
|
||||
dotnet_naming_style.begins_with_i.word_separator =
|
||||
dotnet_naming_style.begins_with_i.capitalization = pascal_case
|
||||
@@ -58,6 +58,8 @@ bld/
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
.vscode/
|
||||
bin/
|
||||
certificates/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
PROPRIETARY LICENSE
|
||||
|
||||
Copyright (c) 2026 IFRI TECHNOLOGIES (PTY) Ltd. All rights reserved.
|
||||
|
||||
This software and its associated documentation (the "Software") are the
|
||||
proprietary property of IFRI TECHNOLOGIES (PTY) Ltd.
|
||||
|
||||
The Software is provided for internal use only. Unauthorized copying,
|
||||
distribution, modification, or use of this file via any medium is
|
||||
strictly prohibited.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,41 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Communications;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetInvitesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/communications/invites", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Invites.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderByDescending(i => i.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of sent invitations")
|
||||
.WithName(typeof(GetInvitesEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Communications);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Communications;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class SendInviteEndpoint : IEndpoint
|
||||
{
|
||||
public record InviteRequest(long OrganisationId, long InvitedOrganisationId, long AwardId, long ContractId, Guid Recipient, string Message, NotificationPlatform Platform);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/communications/invite", async (InviteRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var currentUserId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var invite = new Domain.Entities.Invite
|
||||
{
|
||||
OrganisationId = request.OrganisationId,
|
||||
InvitedOrganisationId = request.InvitedOrganisationId,
|
||||
AwardId = request.AwardId,
|
||||
ContractId = request.ContractId,
|
||||
Recipient = request.Recipient,
|
||||
CreatedBy = currentUserId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Invites.Add(invite);
|
||||
|
||||
context.Notifications.Add(new Domain.Entities.Notification
|
||||
{
|
||||
OrganisationId = request.OrganisationId,
|
||||
Recipient = request.Recipient,
|
||||
Platform = request.Platform,
|
||||
Status = NotificationStatus.Pending,
|
||||
Subject = "Invitation to Onboard",
|
||||
Message = request.Message,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Ok(invite)
|
||||
: Results.BadRequest("Failed to create invite");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Trigger an invite to an organization or candidate (specifying NotificationPlatform)")
|
||||
.WithName(typeof(SendInviteEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Invite>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Communications);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Communications;
|
||||
|
||||
public class SyncEvent : EventBase, IEvent
|
||||
{
|
||||
public string Name { get; set; } = nameof(SyncEvent);
|
||||
}
|
||||
|
||||
public class SyncEventHandler : INotificationHandler<SyncEvent>
|
||||
{
|
||||
public ValueTask Handle(SyncEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
Trace.WriteLine($"Integration Sync executed. Correlation ID: {notification.CorrelationId}");
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class SyncEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/integration/sync", async (IJobOrchestrator jobOrchestrator,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var syncEvent = new SyncEvent();
|
||||
|
||||
await jobOrchestrator.SendAsync(syncEvent, cancellationToken);
|
||||
|
||||
return Results.Ok(new { Queued = true, syncEvent.CorrelationId });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Trigger integration and data synchronization in the background")
|
||||
.WithName(typeof(SyncEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Communications);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Evidence;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class CreateIndicatorEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateIndicatorRequest(long ProjectId, string Name, UnitOfMeasure UnitOfMeasure, decimal BaselineAmount, decimal TargetAmount);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/evidence/indicators", async (CreateIndicatorRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||
return Results.BadRequest($"Project with ID {request.ProjectId} does not exist.");
|
||||
|
||||
var indicator = new Domain.Entities.Indicator
|
||||
{
|
||||
ProjectId = request.ProjectId,
|
||||
Name = request.Name,
|
||||
UnitOfMeasure = request.UnitOfMeasure,
|
||||
BaselineAmount = request.BaselineAmount,
|
||||
TargetAmount = request.TargetAmount,
|
||||
ActualAmount = request.BaselineAmount,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Indicators.Add(indicator);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/evidence/indicators/{indicator.Id}", indicator)
|
||||
: Results.BadRequest("Failed to create indicator");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Define indicators and baselines/targets (M08)")
|
||||
.WithName(typeof(CreateIndicatorEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<PostFundManagement.Domain.Entities.Indicator>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Evidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Evidence;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class CreateMilestoneEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateMilestoneRequest(long ProjectId, DateTime DueAt, string Name, Priority Priority);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/evidence/milestones", async (CreateMilestoneRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||
return Results.BadRequest($"Project with ID {request.ProjectId} does not exist.");
|
||||
|
||||
var milestone = new Domain.Entities.Milestone
|
||||
{
|
||||
ProjectId = request.ProjectId,
|
||||
DueAt = request.DueAt,
|
||||
Name = request.Name,
|
||||
Priority = request.Priority,
|
||||
Status = ApprovalStatus.Pending,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Milestones.Add(milestone);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/evidence/milestones/{milestone.Id}", milestone)
|
||||
: Results.BadRequest("Failed to create a new milestone");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Define contractual milestones (M07)")
|
||||
.WithName(typeof(CreateMilestoneRequest).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Milestone>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Evidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Evidence;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetEvidenceFilesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/evidence/files", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Evidences.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderByDescending(e => e.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of all uploaded evidence files metadata")
|
||||
.WithName(typeof(GetEvidenceFilesEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Evidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Evidence;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetIndicatorsEndpoint : IEndpoint
|
||||
{
|
||||
public record RecordActualRequest(decimal ActualAmount);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/evidence/indicators", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Indicators.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderByDescending(i => i.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of indicators")
|
||||
.WithName(typeof(GetIndicatorsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Evidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Evidence;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetMilestonesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/evidence/milestones", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Milestones.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderByDescending(m => m.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of all milestones")
|
||||
.WithName(typeof(GetMilestonesEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Evidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Evidence;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetProjectMilestonesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/evidence/milestones/project/{projectId:long}", async (long projectId,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var milestones = await context.Milestones.AsNoTracking()
|
||||
.Where(m => m.ProjectId == projectId)
|
||||
.OrderBy(m => m.DueAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(milestones);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of milestones for a project")
|
||||
.WithName(typeof(GetProjectMilestonesEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Evidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Evidence;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class RecordIndicatorActualAmountEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/evidence/indicators/{id:long}/actuals/{actualAmount:decimal}", async (long id, decimal actualAmount,
|
||||
ClaimsPrincipal principal, IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var indicator = await context.Indicators.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
|
||||
|
||||
if (indicator is null)
|
||||
return Results.NotFound($"Indicator with ID {id} does not exist.");
|
||||
|
||||
indicator.ActualAmount = actualAmount;
|
||||
|
||||
context.Indicators.Update(indicator);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Ok(indicator)
|
||||
: Results.BadRequest("Failed to record actual amount");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Record actual values for indicators (M08)")
|
||||
.WithName(typeof(RecordIndicatorActualAmountEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Indicator>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Evidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Evidence;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class UploadEvidenceFilesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/evidence/files", async (HttpRequest request, ClaimsPrincipal principal, IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
[FromKeyedServices(Constants.EvidenceS3SettingsSection)] IS3Service s3Service, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
if (!request.HasFormContentType)
|
||||
return Results.BadRequest("Request must be a multipart form.");
|
||||
|
||||
var form = await request.ReadFormAsync(cancellationToken);
|
||||
var file = form.Files.GetFile("file");
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
return Results.BadRequest("No file uploaded or file is empty.");
|
||||
|
||||
_ = long.TryParse(form["projectId"], out var projectId);
|
||||
_ = long.TryParse(form["milestoneId"], out var milestoneId);
|
||||
_ = long.TryParse(form["indicatorId"], out var indicatorId);
|
||||
_ = long.TryParse(form["activityId"], out var activityId);
|
||||
_ = Enum.TryParse<EvidenceType>(form["type"], out var type);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
|
||||
var uploadResult = await s3Service.UploadFileAsync(file.FileName, stream, file.ContentType, cancellationToken);
|
||||
|
||||
if (uploadResult.IsFailed)
|
||||
return Results.BadRequest(uploadResult.Errors.FirstOrDefault()?.Message ?? "File upload failed.");
|
||||
|
||||
var documentUrl = uploadResult.Value;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var evidence = new Domain.Entities.Evidence
|
||||
{
|
||||
ProjectId = projectId,
|
||||
MilestoneId = milestoneId,
|
||||
IndicatorId = indicatorId,
|
||||
ActivityId = activityId,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Version = 1,
|
||||
Type = type,
|
||||
DocumentUrl = documentUrl,
|
||||
Status = ApprovalStatus.Pending
|
||||
};
|
||||
|
||||
context.Evidences.Add(evidence);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/evidence/files/{evidence.Id}", evidence)
|
||||
: Results.BadRequest("Evidence upload failed");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.DisableAntiforgery()
|
||||
.WithDescription("Upload supporting evidence file to S3 and register metadata (M14)")
|
||||
.WithName(typeof(UploadEvidenceFilesEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<PostFundManagement.Domain.Entities.Evidence>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Evidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Execution;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class ApproveDisbursementEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/execution/disbursements/{id:long}/approve", async (long id, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var disbursement = await context.Disbursements.FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
|
||||
|
||||
if (disbursement == null)
|
||||
return Results.NotFound($"Disbursement with ID {id} does not exist.");
|
||||
|
||||
disbursement.Status = ApprovalStatus.Approved;
|
||||
disbursement.UpdatedBy = userId;
|
||||
disbursement.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
context.Disbursements.Update(disbursement);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Ok(disbursement)
|
||||
: Results.BadRequest("Failed to approve payment");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Approve disbursement and release payment (M06)")
|
||||
.WithName(typeof(ApproveDisbursementEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Disbursement>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Execution);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Execution;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class CreateDisbursementEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateDisbursementRequest(long ProjectId, long? MilestoneId, decimal Amount);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/execution/disbursements", async (CreateDisbursementRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
if (request.Amount <= 0)
|
||||
return Results.BadRequest("Amount must be greater than zero.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||
return Results.BadRequest($"Project with ID {request.ProjectId} does not exist.");
|
||||
|
||||
var disbursement = new Domain.Entities.Disbursement
|
||||
{
|
||||
ProjectId = request.ProjectId,
|
||||
MilestoneId = request.MilestoneId,
|
||||
Amount = request.Amount,
|
||||
Status = ApprovalStatus.Pending,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Disbursements.Add(disbursement);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/execution/disbursements/{disbursement.Id}", disbursement)
|
||||
: Results.BadRequest("Failed to create disbursement");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Create a new disbursement schedule (M06)")
|
||||
.WithName(typeof(CreateDisbursementEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Disbursement>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Execution);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Execution;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class CreateRiskEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateRiskRequest(long ProjectId, RiskLikelihood Likelihood, RiskImpact Impact, string Name, string Description);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/execution/risks", async (CreateRiskRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||
return Results.BadRequest($"Project with ID {request.ProjectId} does not exist.");
|
||||
|
||||
var risk = new Domain.Entities.Risk
|
||||
{
|
||||
ProjectId = request.ProjectId,
|
||||
Likelihood = request.Likelihood,
|
||||
Impact = request.Impact,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Risks.Add(risk);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/execution/risks/{risk.Id}", risk)
|
||||
: Results.BadRequest("Failed to create project risk");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Add risk to risk register (M09)")
|
||||
.WithName(typeof(CreateRiskEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Risk>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Execution);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Execution;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetDisbursementsEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/execution/disbursements", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Disbursements.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderByDescending(d => d.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of disbursements")
|
||||
.WithName(typeof(GetDisbursementsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Execution);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Execution;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetProjectRisksEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/execution/risks/project/{projectId:long}", async (long projectId,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var risks = await context.Risks.AsNoTracking()
|
||||
.Where(r => r.ProjectId == projectId)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(risks);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of risks for a project")
|
||||
.WithName(typeof(GetProjectRisksEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Execution);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Execution;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetRisksEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/execution/risks", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Risks.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of all risks")
|
||||
.WithName(typeof(GetRisksEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Execution);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Governance;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class CreateOrganisationEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateOrganisationRequest(string Name, string RegistrationNo, string Email, OrganisationType Type);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/governance/organisations", async (CreateOrganisationRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var organisation = new Domain.Entities.Organisation
|
||||
{
|
||||
Name = request.Name,
|
||||
RegistrationNo = request.RegistrationNo,
|
||||
Email = request.Email,
|
||||
Type = request.Type,
|
||||
Status = ActivityStatus.Active,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Organisations.Add(organisation);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/governance/organisations/{organisation.Id}", organisation)
|
||||
: Results.BadRequest("Failed to create organisation");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Create a new organization profile")
|
||||
.WithName(typeof(CreateOrganisationEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Organisation>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Governance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Governance;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class DashboardEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/governance/dashboard", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var totalPortfolioValue = await context.Awards.SumAsync(a => a.Amount, cancellationToken);
|
||||
var activeAwardsCount = await context.Awards.CountAsync(a => a.Status == ApprovalStatus.Approved, cancellationToken);
|
||||
|
||||
var disbursedAmount = await context.Disbursements.Where(d => d.Status == ApprovalStatus.Approved)
|
||||
.SumAsync(d => d.Amount, cancellationToken);
|
||||
|
||||
var totalProjects = await context.Projects.CountAsync(cancellationToken);
|
||||
var onTrackProjects = await context.Projects.CountAsync(p => p.Status == ApprovalStatus.Approved, cancellationToken);
|
||||
|
||||
var onTrackPercentage = totalProjects > 0
|
||||
? Math.Round((double)onTrackProjects / totalProjects * 100, 1)
|
||||
: 100.0;
|
||||
|
||||
var atRiskCount = await context.Risks.CountAsync(r => r.Impact == RiskImpact.Critical || r.Impact == RiskImpact.Major, cancellationToken);
|
||||
|
||||
var beneficiariesReached = await context.Beneficiaries.SumAsync(b => b.ReachedCount, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
PortfolioValue = totalPortfolioValue,
|
||||
ActiveAwards = activeAwardsCount,
|
||||
Disbursed = disbursedAmount,
|
||||
OnTrackPercentage = onTrackPercentage,
|
||||
AtRiskCount = atRiskCount,
|
||||
BeneficiariesReached = beneficiariesReached
|
||||
});
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get executive portfolio dashboard metrics and KPIs")
|
||||
.WithName(typeof(DashboardEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Governance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Governance;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetInstrumentsEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/governance/instruments", async (IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var instruments = await context.Instruments.AsNoTracking().ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(instruments);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get all funding instruments")
|
||||
.WithName(typeof(GetInstrumentsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<List<Domain.Entities.Instrument>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Governance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Governance;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetOrganisationByIdEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/governance/organisations/{id:long}", async (long id,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var organisation = await context.Organisations.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
|
||||
|
||||
return organisation != null ? Results.Ok(organisation) : Results.NotFound();
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get organization profile by ID")
|
||||
.WithName(typeof(GetOrganisationByIdEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Organisation>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Governance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Governance;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetOrganisationsEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateOrganisationRequest(string Name, string RegistrationNo, string Email, OrganisationType Type);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/governance/organisations", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Organisations.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderBy(o => o.Name)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a paginated directory of organizations")
|
||||
.WithName(typeof(GetOrganisationsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Governance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Governance;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetPortfoliosEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/governance/portfolios", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var portfolios = await context.Portfolios.AsNoTracking().ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(portfolios);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get all portfolios")
|
||||
.WithName(typeof(GetPortfoliosEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<List<Domain.Entities.Portfolio>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Governance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Governance;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetProgrammesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/governance/programmes", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var programmes = await context.Programmes.AsNoTracking().ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(programmes);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get all programmes")
|
||||
.WithName(typeof(GetProgrammesEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<List<Domain.Entities.Programme>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Governance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class CreateAwardEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateAwardRequest(long OrganisationId, long ProgrammeId, decimal Amount);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/grants/awards", async (CreateAwardRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
if (request.Amount <= 0)
|
||||
return Results.BadRequest("Approved amount must be greater than zero.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Organisations.AnyAsync(o => o.Id == request.OrganisationId, cancellationToken))
|
||||
return Results.BadRequest($"Organisation with ID {request.OrganisationId} does not exist.");
|
||||
|
||||
if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken))
|
||||
return Results.BadRequest($"Programme with ID {request.ProgrammeId} does not exist.");
|
||||
|
||||
var award = new Domain.Entities.Award
|
||||
{
|
||||
OrganisationId = request.OrganisationId,
|
||||
ProgrammeId = request.ProgrammeId,
|
||||
Amount = request.Amount,
|
||||
Status = ApprovalStatus.Pending,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Awards.Add(award);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/grants/awards/{award.Id}", award)
|
||||
: Results.BadRequest("Failred to create award");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Register an approved funding award (M01)")
|
||||
.WithName(typeof(CreateAwardEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Award>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class CreateBudgetEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateBudgetRequest(long ProjectId, string Name, decimal Amount);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/grants/budgets", async (CreateBudgetRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
if (request.Amount <= 0)
|
||||
return Results.BadRequest("Budget amount must be greater than zero.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
|
||||
return Results.BadRequest($"Project with ID {request.ProjectId} does not exist.");
|
||||
|
||||
var budget = new Domain.Entities.Budget
|
||||
{
|
||||
ProjectId = request.ProjectId,
|
||||
Name = request.Name,
|
||||
Amount = request.Amount,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Budgets.Add(budget);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/grants/budgets/{budget.Id}", budget)
|
||||
: Results.BadRequest("Failed to create budget");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Create a new budget line (M05)")
|
||||
.WithName(typeof(CreateBudgetEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Budget>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class CreateContractEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateContractRequest(long AwardId, decimal TotalValue, DateTime EffectiveAt, DateTime ExpiresAt);
|
||||
public record ExecuteContractRequest(string ExternalSignatureId, string SignedDocumentUrl);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/grants/contracts", async (CreateContractRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var award = await context.Awards.FirstOrDefaultAsync(a => a.Id == request.AwardId, cancellationToken);
|
||||
|
||||
if (award == null)
|
||||
return Results.BadRequest($"Award with ID {request.AwardId} does not exist.");
|
||||
|
||||
var contract = new Domain.Entities.Contract
|
||||
{
|
||||
AwardId = request.AwardId,
|
||||
TotalValue = request.TotalValue,
|
||||
EffectiveAt = request.EffectiveAt,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
Status = ContractStatus.Draft,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Contracts.Add(contract);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/grants/contracts/{contract.Id}", contract)
|
||||
: Results.BadRequest("Failed to create the contract");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Create a contract record from an approved award (M02)")
|
||||
.WithName(typeof(CreateContractEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Contract>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class ExecuteContractEndpoint : IEndpoint
|
||||
{
|
||||
public record ExecuteContractRequest(string ExternalSignatureId, string SignedDocumentUrl);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/grants/contracts/{id:long}/execute", async (long id, ExecuteContractRequest request,
|
||||
ClaimsPrincipal principal, IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var contract = await context.Contracts.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||
|
||||
if (contract == null)
|
||||
return Results.NotFound($"Contract with ID {id} does not exist.");
|
||||
|
||||
contract.ExternalSignatureId = request.ExternalSignatureId;
|
||||
contract.SignedDocumentUrl = request.SignedDocumentUrl;
|
||||
contract.Status = ContractStatus.Active;
|
||||
|
||||
context.Contracts.Update(contract);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Ok(contract)
|
||||
: Results.BadRequest("Failed to execute contract");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Execute the contract and lock the contractual version")
|
||||
.WithName(typeof(ExecuteContractEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Contract>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetAwardByIdEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/grants/awards/{id:long}", async (long id, IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var award = await context.Awards.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
|
||||
return award != null ? Results.Ok(award) : Results.NotFound();
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get award by ID")
|
||||
.WithName(typeof(GetAwardByIdEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Award>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetAwardsEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/grants/awards", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Awards.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderByDescending(a => a.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of awards")
|
||||
.WithName(typeof(GetAwardsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetBudgetByIdEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateBudgetRequest(long ProjectId, string Name, decimal Amount);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/grants/budgets/{id:long}", async (long id, IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var budget = await context.Budgets.AsNoTracking().FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
|
||||
|
||||
return budget != null ? Results.Ok(budget) : Results.NotFound();
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get budget by ID")
|
||||
.WithName(typeof(GetBudgetByIdEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Budget>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetBudgetsEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/grants/budgets", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Budgets.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderByDescending(b => b.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get all budgets")
|
||||
.WithName(typeof(GetBudgetsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetContractByIdEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateContractRequest(long AwardId, decimal TotalValue, DateTime EffectiveAt, DateTime ExpiresAt);
|
||||
public record ExecuteContractRequest(string ExternalSignatureId, string SignedDocumentUrl);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/grants/contracts/{id:long}", async (long id, IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var contract = await context.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||
return contract != null ? Results.Ok(contract) : Results.NotFound();
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get contract by ID")
|
||||
.WithName(typeof(GetContractByIdEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Contract>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Grants;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetContractsEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/grants/contracts", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Contracts.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderByDescending(c => c.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get all contracts")
|
||||
.WithName(typeof(GetContractsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Grants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Projects;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class CreateActivityEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateActivityRequest(long? MilestoneId, string Category, decimal Amount, string Title, string Description, DateTime? PerformedAt);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/projects/{projectId:long}/activities", async (long projectId, CreateActivityRequest request,
|
||||
ClaimsPrincipal principal, IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Projects.AnyAsync(p => p.Id == projectId, cancellationToken))
|
||||
return Results.BadRequest($"Project with ID {projectId} does not exist.");
|
||||
|
||||
if (request.MilestoneId.HasValue)
|
||||
if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
|
||||
return Results.BadRequest($"Milestone with ID {request.MilestoneId.Value} does not exist.");
|
||||
|
||||
var activity = new Domain.Entities.Activity
|
||||
{
|
||||
ProjectId = projectId,
|
||||
MilestoneId = request.MilestoneId,
|
||||
Category = request.Category,
|
||||
Amount = request.Amount,
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
PerformedAt = request.PerformedAt,
|
||||
Status = ApprovalStatus.Pending,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Activities.Add(activity);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/projects/{projectId}/activities/{activity.Id}", activity)
|
||||
: Results.BadRequest("Failed to create activity");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Add a new activity under project (M04)")
|
||||
.WithName(typeof(CreateActivityEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Activity>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Projects;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class CreateProjectEndpoint : IEndpoint
|
||||
{
|
||||
public record CreateProjectRequest(long ProgrammeId, string Name, string Description, IndustrySector Sector, ProjectType ProjectType);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/projects", async (CreateProjectRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken))
|
||||
return Results.BadRequest($"Programme with ID {request.ProgrammeId} does not exist.");
|
||||
|
||||
var project = new Domain.Entities.Project
|
||||
{
|
||||
ProgrammeId = request.ProgrammeId,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Sector = request.Sector,
|
||||
ProjectType = request.ProjectType,
|
||||
Status = ApprovalStatus.Pending,
|
||||
CreatedBy = userId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Projects.Add(project);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Created($"api/projects/{project.Id}", project)
|
||||
: Results.BadRequest("Failed to create prject");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Create a new project (M04)")
|
||||
.WithName(typeof(CreateProjectEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Project>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Projects;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetActivitiesEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/projects/{projectId:long}/activities", async (long projectId,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var activities = await context.Activities.AsNoTracking()
|
||||
.Where(a => a.ProjectId == projectId)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(activities);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of activities for a project")
|
||||
.WithName(typeof(GetActivitiesEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Projects;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetProjectByIdEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/projects/{id:long}", async (long id, IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var project = await context.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||
|
||||
return project != null
|
||||
? Results.Ok(project)
|
||||
: Results.NotFound();
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get project by ID")
|
||||
.WithName(typeof(GetProjectByIdEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Project>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Projects;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetProjectsEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/projects", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Projects.AsNoTracking();
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Get a list of projects")
|
||||
.WithName(typeof(GetProjectsEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Projects;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class UpdateProjectEndpoint : IEndpoint
|
||||
{
|
||||
public record UpdateProjectStatusRequest(ApprovalStatus Status);
|
||||
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/projects/{id:long}/status", async (long id, UpdateProjectStatusRequest request, ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
return Results.BadRequest("Missing or invalid 'sid' claim.");
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var project = await context.Projects.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||
|
||||
if (project == null)
|
||||
return Results.NotFound($"Project with ID {id} does not exist.");
|
||||
|
||||
project.Status = request.Status;
|
||||
project.UpdatedBy = userId;
|
||||
project.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
context.Projects.Update(project);
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Ok(project)
|
||||
: Results.BadRequest("Failed to update project");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Update project status (M04)")
|
||||
.WithName(typeof(UpdateProjectEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.Project>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Projects);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Users;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class GetUsersEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("api/users", async (IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
int page = 1, int pageSize = 10, ActivityStatus? status = null, string? email = null, string?
|
||||
sortBy = null, bool sortDescending = false, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 10;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var query = context.Users.AsNoTracking();
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(u => u.Status == status.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
query = query.Where(u => u.Email != null && u.Email.Contains(email));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sortBy))
|
||||
query = sortBy.ToLowerInvariant() switch
|
||||
{
|
||||
"email" => sortDescending ? query.OrderByDescending(u => u.Email) : query.OrderBy(u => u.Email),
|
||||
"lastloginat" => sortDescending ? query.OrderByDescending(u => u.LastLoginAt) : query.OrderBy(u => u.LastLoginAt),
|
||||
"status" => sortDescending ? query.OrderByDescending(u => u.Status) : query.OrderBy(u => u.Status),
|
||||
_ => sortDescending ? query.OrderByDescending(u => u.Id) : query.OrderBy(u => u.Id)
|
||||
};
|
||||
else
|
||||
query = query.OrderBy(u => u.Id);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(new { Items = items, TotalCount = totalCount, Page = page, PageSize = pageSize });
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Paginated, searchable directory of system users. Supports filtering by ActivityStatus, searching by Email, and sorting")
|
||||
.WithName(typeof(GetUsersEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Users);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Api;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Endpoints.Users;
|
||||
|
||||
[ApiVersionTarget(1)]
|
||||
public class SyncUserEndpoint : IEndpoint
|
||||
{
|
||||
public void Map(IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapPost("api/users/sync", async (ClaimsPrincipal principal,
|
||||
IDbContextFactory<ApplicationDbContext> contextFactory, CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
|
||||
var emailClaim = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sidClaim))
|
||||
return Results.BadRequest("Missing 'sid' claim in user token.");
|
||||
|
||||
if (!Guid.TryParse(sidClaim, out var userId))
|
||||
userId = new Guid(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(sidClaim)));
|
||||
|
||||
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||
var isNew = user == null;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
user = new Domain.Entities.User
|
||||
{
|
||||
Id = userId,
|
||||
Email = emailClaim,
|
||||
Status = ActivityStatus.Active,
|
||||
LastLoginAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
context.Users.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
user!.Email = emailClaim;
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
context.Users.Update(user);
|
||||
}
|
||||
|
||||
return await context.SaveChangesAsync(cancellationToken) > 0
|
||||
? Results.Ok(user)
|
||||
: Results.BadRequest("Failed to sync user");
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithDescription("Synchronise caller user profile using id_token's SID, inserting/updating user details in database")
|
||||
.WithName(typeof(SyncUserEndpoint).ToEndpointName())
|
||||
.MapToApiVersion(new ApiVersion(1))
|
||||
.Produces<Domain.Entities.User>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Users);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
using static PostFundManagement.Domain.Extensions.Constants;
|
||||
|
||||
namespace PostFundManagement.Infrastructure.Extensions;
|
||||
namespace PostFundManagement.Api.Extensions;
|
||||
|
||||
public static class Postgres
|
||||
{
|
||||
+36
-7
@@ -6,7 +6,7 @@ using PostFundManagement.Infrastructure.Database;
|
||||
|
||||
namespace PostFundManagement.Api.Extensions;
|
||||
|
||||
public static class Api
|
||||
public static class Security
|
||||
{
|
||||
public static IServiceCollection AddSecurityApiSdk(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
@@ -42,6 +42,8 @@ public static class Api
|
||||
|
||||
public static IServiceCollection AddWebSecurity(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddAuthorization();
|
||||
|
||||
var certString = configuration["DataProtection:Certificate"] ?? configuration["DataProtection__Certificate"];
|
||||
var certPassword = configuration["DataProtection:Password"] ?? configuration["DataProtection__Password"];
|
||||
|
||||
@@ -52,18 +54,18 @@ public static class Api
|
||||
|
||||
services.AddDataProtection().PersistKeysToDbContext<DataProtectionDbContext>()
|
||||
.ProtectKeysWithCertificate(certificate)
|
||||
.SetApplicationName("LiteCharmsApp");
|
||||
.SetApplicationName("PfmApp");
|
||||
|
||||
services.Configure<DataProtectionOptions>(options => options.ApplicationDiscriminator = "LiteCharmsApp");
|
||||
services.Configure<DataProtectionOptions>(options => options.ApplicationDiscriminator = "PfmApp");
|
||||
|
||||
services.ConfigureCookieOidcSameSiteSupport();
|
||||
|
||||
var configSection = configuration.GetSection(nameof(SecuritySettings));
|
||||
var configSection = configuration.GetSection(nameof(SecurityClientSettings));
|
||||
|
||||
var authOptions = new SecuritySettings();
|
||||
var authOptions = new SecurityClientSettings();
|
||||
configSection.Bind(authOptions);
|
||||
|
||||
services.Configure<SecuritySettings>(configSection);
|
||||
services.Configure<SecurityClientSettings>(configSection);
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
@@ -74,7 +76,7 @@ public static class Api
|
||||
{
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.Name = "LiteCharmsApp.Session";
|
||||
options.Cookie.Name = "PfmApp.Session";
|
||||
})
|
||||
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
@@ -121,4 +123,31 @@ public static class Api
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddApiSecurity(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var configSection = configuration.GetSection(nameof(SecuritySettings));
|
||||
|
||||
var authOptions = new SecuritySettings();
|
||||
configSection.Bind(authOptions);
|
||||
|
||||
services.Configure<SecuritySettings>(configSection);
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = authOptions.Authority;
|
||||
options.Audience = authOptions.Audience;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidIssuer = authOptions.Authority,
|
||||
ValidateAudience = true,
|
||||
ValidateIssuer = true,
|
||||
};
|
||||
});
|
||||
|
||||
services.AddAuthorization();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,89 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>59a05094-4ac2-472d-af04-3407e909432d</UserSecretsId>
|
||||
<AssemblyOriginatorKeyFile>..\PostFundManagement.snk</AssemblyOriginatorKeyFile>
|
||||
<SignAssembly>True</SignAssembly>
|
||||
<PackageLicenseFile>..\LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Security (IODC)-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.12.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.16.20" />
|
||||
</ItemGroup>
|
||||
<PackageReference Include="IdentityModel.AspNetCore" Version="4.3.0" />
|
||||
<PackageReference Include="IdentityModel.AspNetCore.OAuth2introspection" Version="6.2.0" />
|
||||
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
|
||||
<PackageReference Include="IdentityModel" Version="6.2.0" />
|
||||
<PackageReference Include="KubernetesClient" Version="19.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" />
|
||||
|
||||
<!-- Global Usings -->
|
||||
<ItemGroup>
|
||||
<Using Include="System.Security.Cryptography.X509Certificates" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
|
||||
<Using Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<Using Include="Microsoft.IdentityModel.Tokens" />
|
||||
<Using Include="System.Security.Claims" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Health Checks -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI" Version="9.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
|
||||
|
||||
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
|
||||
<Using Include="Microsoft.AspNetCore.Diagnostics.HealthChecks" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- API Versioning -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AccessTokenClient.Extensions" Version="5.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Abstractions" Version="10.2.1" />
|
||||
<PackageReference Include="Asp.Versioning.Http" Version="10.2.2" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
|
||||
|
||||
<Using Include="Asp.Versioning" />
|
||||
<Using Include="Asp.Versioning.Builder" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- API Documentation -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.17.1" />
|
||||
|
||||
<Using Include="Scalar.AspNetCore" />
|
||||
<Using Include="Microsoft.OpenApi" />
|
||||
<Using Include="Microsoft.AspNetCore.OpenApi" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- file nesting -->
|
||||
<ItemGroup>
|
||||
<ProjectCapability Include="ConfigurableFileNesting" />
|
||||
<ProjectCapability Include="ConfigurableFileNestingFeatureEnabled" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- CQRS -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Mediator.SourceGenerator" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
<Using Include="FluentResults" />
|
||||
<Using Include="Mediator" />
|
||||
<Using Include="Quartz" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Shared Global Usings -->
|
||||
<ItemGroup>
|
||||
<Using Include="System.Web" />
|
||||
<Using Include="System.Diagnostics" />
|
||||
<Using Include="System.Reflection" />
|
||||
<Using Include="Microsoft.AspNetCore.Mvc" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore" />
|
||||
<Using Include="System.ComponentModel.DataAnnotations" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection.Extensions" />
|
||||
<Using Include="System.Security.Cryptography.X509Certificates" />
|
||||
<Using Include="Microsoft.AspNetCore.DataProtection" />
|
||||
<Using Include="Refit" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,22 +1,99 @@
|
||||
using Scalar.AspNetCore;
|
||||
using PostFundManagement.Api.Extensions;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Domain.Health;
|
||||
using PostFundManagement.Domain.Mediator;
|
||||
using static PostFundManagement.Domain.Extensions.Constants;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddEndpointsApiExplorer()
|
||||
.AddApiVersioning(options => options.ApiVersionReader = new HeaderApiVersionReader());
|
||||
|
||||
builder.Services.AddApiVersioning().AddApiExplorer();
|
||||
|
||||
builder.Services.AddEndpoints(Assembly.GetExecutingAssembly());
|
||||
builder.Services.AddApiServices(builder.Configuration);
|
||||
|
||||
builder.Services.AddMediator();
|
||||
builder.Services.AddSecurityApiSdk(builder.Configuration);
|
||||
builder.Services.AddApiSecurity(builder.Configuration);
|
||||
|
||||
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
|
||||
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
|
||||
|
||||
builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration);
|
||||
builder.Services.AddEmailServices(builder.Configuration);
|
||||
builder.Services.AddHashServices(builder.Configuration);
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddDataProtectionDatabase(builder.Configuration);
|
||||
builder.Services.AddApplicationDbContext(builder.Configuration);
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<QuartzHealthCheck>("QuartzScheduler")
|
||||
.AddCheck<PostgresHealthCheck>("PostgresDatabase")
|
||||
.AddCheck("Self", () => HealthCheckResult.Healthy());
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapScalarApiReference(options =>
|
||||
{
|
||||
options.WithTitle("Post-fund Management API")
|
||||
.WithTheme(ScalarTheme.BluePlanet);
|
||||
});
|
||||
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
|
||||
var scheduler = await schedulerFactory.GetScheduler(DefaultSchedulerName);
|
||||
|
||||
app.MapOpenApi();
|
||||
}
|
||||
if (!scheduler!.IsStarted)
|
||||
await scheduler.Start();
|
||||
|
||||
app.UseHsts();
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
ApiVersionSet versionSet = app.NewApiVersionSet("v1")
|
||||
.HasApiVersion(new ApiVersion(1))
|
||||
.HasApiVersion(new ApiVersion(2))
|
||||
.ReportApiVersions()
|
||||
.Build();
|
||||
|
||||
var versionGroups = new Dictionary<int, RouteGroupBuilder>
|
||||
{
|
||||
{ 1, app.MapGroup("v{version:apiVersion}").WithApiVersionSet(versionSet) }
|
||||
};
|
||||
|
||||
app.MapEndpoints(versionGroups);
|
||||
|
||||
app.UseHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
Predicate = _ => true,
|
||||
AllowCachingResponses = true,
|
||||
ResponseWriter = HealthChecks.UI.Client.UIResponseWriter.WriteHealthCheckUIResponse
|
||||
});
|
||||
|
||||
app.MapHealthChecksUI(options => { options.UIPath = "/healthui"; });
|
||||
app.UseHealthChecks("/ready");
|
||||
|
||||
app.MapOpenApi();
|
||||
|
||||
var apiVersions = app.DescribeApiVersions()
|
||||
.OrderByDescending(o => o.ApiVersion.MajorVersion)
|
||||
.ToList();
|
||||
|
||||
foreach (var description in app.DescribeApiVersions().OrderByDescending(o => o.ApiVersion.MajorVersion))
|
||||
app.MapScalarApiReference($"/openapi/{description.GroupName}", (options, context) =>
|
||||
{
|
||||
options.AddServer(new ScalarServer($"https://{context.Request.Host}"));
|
||||
options.WithOpenApiRoutePattern($"/openapi/{description.GroupName}.json");
|
||||
options.WithTheme(ScalarTheme.DeepSpace);
|
||||
options.Agent = new ScalarAgentOptions { Disabled = true };
|
||||
options.Authentication = new ScalarAuthenticationOptions { PreferredSecuritySchemes = ["Bearer"] };
|
||||
});
|
||||
|
||||
var latestVersionGroup = apiVersions.FirstOrDefault()?.GroupName ?? "v1";
|
||||
|
||||
app.MapGet("/", () => Results.Redirect($"/openapi/{latestVersionGroup}"))
|
||||
.ExcludeFromDescription();
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
|
||||
app.Run();
|
||||
@@ -1,5 +1,15 @@
|
||||
{
|
||||
"MachineIdentity": {
|
||||
"GeneralS3Settings": {
|
||||
"ServiceUrl": "http://192.168.1.177:30900",
|
||||
"Region": "garage",
|
||||
"BucketName": "pfm.general",
|
||||
"CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za"
|
||||
},
|
||||
"Monitoring": {
|
||||
"Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889",
|
||||
"ServiceName": "MidrandBooks.DEV"
|
||||
},
|
||||
"SecuritySettings": {
|
||||
"Authority": "https://sts.security.khongisa.co.za",
|
||||
"Audience": "pfm-api-dev"
|
||||
},
|
||||
|
||||
@@ -2,17 +2,19 @@ namespace PostFundManagement.Domain.Extensions;
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
public const string DefaultSchedulerName = "pfm-scheduler";
|
||||
|
||||
public const string GeneralS3SettingsSection = "PfmS3Settings";
|
||||
|
||||
public const string EvidenceS3SettingsSection = "EvidenceS3Settings";
|
||||
|
||||
public const string ContractS3SettingsSection = "ContractS3Settings";
|
||||
|
||||
public const string GeneralQuotesBucketName = "pfm.general";
|
||||
public const string GeneralBucketName = "pfm.general";
|
||||
|
||||
public const string EvidenceQuotesBucketName = "pfm.evidence";
|
||||
public const string EvidenceBucketName = "pfm.evidence";
|
||||
|
||||
public const string ContractQuotesBucketName = "pfm.contract";
|
||||
public const string ContractBucketName = "pfm.contract";
|
||||
|
||||
public const string DatabaseConfigName = "PfmDatabase";
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using PostFundManagement.Domain.Configuration.Email;
|
||||
using PostFundManagement.Domain.Services;
|
||||
|
||||
namespace PostFundManagement.Domain.Extensions;
|
||||
|
||||
public static class Email
|
||||
{
|
||||
public const string EmailFromName = "PFM Team";
|
||||
public const string EmailFromAddress = "info@pfm.co.za"; // TODO: replace with real address
|
||||
|
||||
public static IServiceCollection AddEmailServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<SmtpSettings>(configuration.GetSection("Email"));
|
||||
|
||||
services.AddSingleton<EmailService>();
|
||||
|
||||
services.AddOpenTelemetry()
|
||||
.WithTracing(tracing => tracing.AddSource("Pfm.EmailService"))
|
||||
.WithMetrics(metrics => metrics.AddMeter("Pfm.EmailService"));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace PostFundManagement.Domain.Extensions;
|
||||
|
||||
public static class EndpointTags
|
||||
{
|
||||
public const string Governance = nameof(Governance);
|
||||
|
||||
public const string Projects = nameof(Projects);
|
||||
|
||||
public const string Grants = nameof(Grants);
|
||||
|
||||
public const string Execution = nameof(Execution);
|
||||
|
||||
public const string Evidence = "Evidence & M&E";
|
||||
|
||||
public const string Communications = nameof(Communications);
|
||||
|
||||
public const string Users = nameof(Users);
|
||||
}
|
||||
@@ -6,8 +6,6 @@ namespace PostFundManagement.Domain.Extensions;
|
||||
|
||||
public static class Quartz
|
||||
{
|
||||
public const string DefaultSchedulerName = "tech-shop";
|
||||
|
||||
public static IServiceCollection AddQuartzSchedulerClient(this IServiceCollection services, string schedulerName, IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString(DatabaseConfigName);
|
||||
@@ -29,9 +27,12 @@ public static class Quartz
|
||||
|
||||
storage.UseSystemTextJsonSerializer();
|
||||
storage.SetProperty("quartz.jobStore.clustered", "true");
|
||||
storage.SetProperty("quartz.jobStore.tablePrefix", "qrtz_");
|
||||
|
||||
storage.UsePostgres(connectionString!);
|
||||
storage.UsePostgres(options =>
|
||||
{
|
||||
options.ConnectionString = connectionString!;
|
||||
options.TablePrefix = "quartz.qrtz_";
|
||||
});
|
||||
storage.UseClustering(cluster =>
|
||||
{
|
||||
cluster.CheckinInterval = TimeSpan.FromSeconds(30);
|
||||
@@ -71,9 +72,12 @@ public static class Quartz
|
||||
|
||||
storage.UseSystemTextJsonSerializer();
|
||||
storage.SetProperty("quartz.jobStore.clustered", "true");
|
||||
storage.SetProperty("quartz.jobStore.tablePrefix", "qrtz_");
|
||||
|
||||
storage.UsePostgres(connectionString!);
|
||||
storage.UsePostgres(options =>
|
||||
{
|
||||
options.ConnectionString = connectionString!;
|
||||
options.TablePrefix = "quartz.qrtz_";
|
||||
});
|
||||
storage.UseClustering(cluster =>
|
||||
{
|
||||
cluster.CheckinInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Services;
|
||||
using static PostFundManagement.Domain.Extensions.Constants;
|
||||
|
||||
namespace PostFundManagement.Domain.Extensions;
|
||||
|
||||
public static class S3
|
||||
{
|
||||
public static IServiceCollection AddGarageS3(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{GeneralS3SettingsSection}:ServiceUrl").Value))
|
||||
{
|
||||
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(GeneralBucketName, (provider, client) =>
|
||||
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{GeneralS3SettingsSection}:AccessKey").Value,
|
||||
configuration.GetSection($"{GeneralS3SettingsSection}:SecretKey").Value),
|
||||
new AmazonS3Config
|
||||
{
|
||||
ServiceURL = configuration.GetSection($"{GeneralS3SettingsSection}:ServiceUrl").Value,
|
||||
AuthenticationRegion = configuration.GetSection($"{GeneralS3SettingsSection}:Region").Value,
|
||||
ForcePathStyle = true,
|
||||
EndpointDiscoveryEnabled = true,
|
||||
UseHttp = configuration.GetSection($"{GeneralS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
|
||||
}));
|
||||
|
||||
services.AddKeyedScoped<IS3Service, GeneralS3Service>(GeneralBucketName);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value))
|
||||
{
|
||||
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(EvidenceS3SettingsSection, (provider, client) =>
|
||||
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{EvidenceS3SettingsSection}:AccessKey").Value,
|
||||
configuration.GetSection($"{EvidenceS3SettingsSection}:SecretKey").Value),
|
||||
new AmazonS3Config
|
||||
{
|
||||
ServiceURL = configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value,
|
||||
AuthenticationRegion = configuration.GetSection($"{EvidenceS3SettingsSection}:Region").Value,
|
||||
ForcePathStyle = true,
|
||||
EndpointDiscoveryEnabled = true,
|
||||
UseHttp = configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
|
||||
}));
|
||||
|
||||
services.AddKeyedScoped<IS3Service, EvidenceS3Service>(EvidenceS3SettingsSection);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value))
|
||||
{
|
||||
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(ContractS3SettingsSection, (provider, client) =>
|
||||
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{ContractS3SettingsSection}:AccessKey").Value,
|
||||
configuration.GetSection($"{ContractS3SettingsSection}:SecretKey").Value),
|
||||
new AmazonS3Config
|
||||
{
|
||||
ServiceURL = configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value,
|
||||
AuthenticationRegion = configuration.GetSection($"{ContractS3SettingsSection}:Region").Value,
|
||||
ForcePathStyle = true,
|
||||
EndpointDiscoveryEnabled = true,
|
||||
UseHttp = configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
|
||||
}));
|
||||
|
||||
services.AddKeyedScoped<IS3Service, ContractS3Service>(ContractS3SettingsSection);
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using static PostFundManagement.Domain.Extensions.Constants;
|
||||
|
||||
namespace PostFundManagement.Domain.Health;
|
||||
|
||||
public sealed class PostgresHealthCheck(IConfiguration configuration) : IHealthCheck
|
||||
{
|
||||
private readonly string connectionString = configuration.GetConnectionString(DatabaseConfigName)!;
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT 1";
|
||||
|
||||
await command.ExecuteScalarAsync(cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy($"{DatabaseConfigName} is responsive.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"{DatabaseConfigName} is unreachable.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using static PostFundManagement.Domain.Extensions.Constants;
|
||||
|
||||
namespace PostFundManagement.Domain.Health;
|
||||
|
||||
public sealed class QuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHealthCheck
|
||||
{
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scheduler = await schedulerFactory.GetScheduler(DefaultSchedulerName, cancellationToken);
|
||||
|
||||
if(scheduler == null)
|
||||
return HealthCheckResult.Unhealthy($"Scheduler with name '{DefaultSchedulerName}' not found.");
|
||||
|
||||
if (!scheduler.IsStarted)
|
||||
return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not running");
|
||||
|
||||
await scheduler.CheckExists(new JobKey(Guid.NewGuid().ToString()), cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy($"{DefaultSchedulerName} Quartz scheduler is ready");
|
||||
}
|
||||
catch (SchedulerException)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler cannot connect to the store");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyOriginatorKeyFile>..\PostFundManagement.snk</AssemblyOriginatorKeyFile>
|
||||
<SignAssembly>True</SignAssembly>
|
||||
<PackageLicenseFile>..\LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Transformation -->
|
||||
@@ -37,7 +40,7 @@
|
||||
|
||||
<!-- API SDK Composer-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="15.1.0" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="15.2.0" />
|
||||
|
||||
<Using Include="Refit" />
|
||||
</ItemGroup>
|
||||
@@ -55,7 +58,7 @@
|
||||
<!-- API Documentation -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.16.20" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.17.1" />
|
||||
|
||||
<Using Include="Scalar.AspNetCore" />
|
||||
<Using Include="Microsoft.OpenApi" />
|
||||
@@ -65,7 +68,7 @@
|
||||
<!-- Quartz Scheduler-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Hashids.net" Version="1.7.0" />
|
||||
<PackageReference Include="Meziantou.Analyzer" Version="3.0.157">
|
||||
<PackageReference Include="Meziantou.Analyzer" Version="3.0.177">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -171,8 +174,8 @@
|
||||
|
||||
<!-- Amazon S3 SDK -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.100.8" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.102.1" />
|
||||
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.101" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.102.3" />
|
||||
|
||||
<!-- global Usings -->
|
||||
<Using Include="Amazon.S3" />
|
||||
|
||||
@@ -3,7 +3,7 @@ using static PostFundManagement.Domain.Extensions.Constants;
|
||||
|
||||
namespace PostFundManagement.Domain.Services;
|
||||
|
||||
public sealed class ContractS3Service(IConfiguration configuration, [FromKeyedServices(ContractQuotesBucketName)] IAmazonS3 amazonS3) :
|
||||
public sealed class ContractS3Service(IConfiguration configuration, [FromKeyedServices(ContractBucketName)] IAmazonS3 amazonS3) :
|
||||
S3ServiceBase(amazonS3), IS3Service
|
||||
{
|
||||
protected override string BucketName => configuration.GetSection($"{ContractS3SettingsSection}:BucketName").Value ?? "";
|
||||
|
||||
@@ -3,7 +3,7 @@ using static PostFundManagement.Domain.Extensions.Constants;
|
||||
|
||||
namespace PostFundManagement.Domain.Services;
|
||||
|
||||
public sealed class EvidenceS3Service(IConfiguration configuration, [FromKeyedServices(EvidenceQuotesBucketName)] IAmazonS3 amazonS3) :
|
||||
public sealed class EvidenceS3Service(IConfiguration configuration, [FromKeyedServices(EvidenceBucketName)] IAmazonS3 amazonS3) :
|
||||
S3ServiceBase(amazonS3), IS3Service
|
||||
{
|
||||
protected override string BucketName => configuration.GetSection($"{EvidenceS3SettingsSection}:BucketName").Value ?? "";
|
||||
|
||||
@@ -3,7 +3,7 @@ using static PostFundManagement.Domain.Extensions.Constants;
|
||||
|
||||
namespace PostFundManagement.Domain.Services;
|
||||
|
||||
public sealed class GeneralS3Service(IConfiguration configuration, [FromKeyedServices(GeneralQuotesBucketName)] IAmazonS3 amazonS3) :
|
||||
public sealed class GeneralS3Service(IConfiguration configuration, [FromKeyedServices(GeneralBucketName)] IAmazonS3 amazonS3) :
|
||||
S3ServiceBase(amazonS3), IS3Service
|
||||
{
|
||||
protected override string BucketName => configuration.GetSection($"{GeneralS3SettingsSection}:BucketName").Value ?? "";
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
-- This script is for PostgreSQL
|
||||
|
||||
-- This initializes the database to pristine for Quartz, by first removing any existing Quartz tables
|
||||
-- and then recreating them from scratch.
|
||||
-- Should you only require it to create the tables, set DropDb to 0.
|
||||
-- 1. Create the dedicated schema
|
||||
CREATE SCHEMA IF NOT EXISTS quartz;
|
||||
|
||||
-- 2. Set the search path so the creation script targets this schema
|
||||
SET search_path TO quartz;
|
||||
|
||||
DO $$
|
||||
DECLARE DropDb INT := 1; -- Set this to 0 to skip DROP statements, 1 to include them
|
||||
BEGIN
|
||||
IF DropDb = 1 THEN
|
||||
SET client_min_messages = WARNING;
|
||||
DROP TABLE IF EXISTS qrtz_fired_triggers;
|
||||
DROP TABLE IF EXISTS qrtz_paused_trigger_grps;
|
||||
DROP TABLE IF EXISTS qrtz_scheduler_state;
|
||||
DROP TABLE IF EXISTS qrtz_locks;
|
||||
DROP TABLE IF EXISTS qrtz_simprop_triggers;
|
||||
DROP TABLE IF EXISTS qrtz_simple_triggers;
|
||||
DROP TABLE IF EXISTS qrtz_cron_triggers;
|
||||
DROP TABLE IF EXISTS qrtz_blob_triggers;
|
||||
DROP TABLE IF EXISTS qrtz_triggers;
|
||||
DROP TABLE IF EXISTS qrtz_job_details;
|
||||
DROP TABLE IF EXISTS qrtz_calendars;
|
||||
SET client_min_messages = NOTICE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE qrtz_job_details
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
job_name TEXT NOT NULL,
|
||||
job_group TEXT NOT NULL,
|
||||
description TEXT NULL,
|
||||
job_class_name TEXT NOT NULL,
|
||||
is_durable BOOL NOT NULL,
|
||||
is_nonconcurrent BOOL NOT NULL,
|
||||
is_update_data BOOL NOT NULL,
|
||||
requests_recovery BOOL NOT NULL,
|
||||
job_data BYTEA NULL,
|
||||
PRIMARY KEY (sched_name, job_name, job_group)
|
||||
);
|
||||
|
||||
CREATE TABLE qrtz_triggers
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
trigger_name TEXT NOT NULL,
|
||||
trigger_group TEXT NOT NULL,
|
||||
job_name TEXT NOT NULL,
|
||||
job_group TEXT NOT NULL,
|
||||
description TEXT NULL,
|
||||
next_fire_time BIGINT NULL,
|
||||
prev_fire_time BIGINT NULL,
|
||||
priority INTEGER NULL,
|
||||
trigger_state TEXT NOT NULL,
|
||||
trigger_type TEXT NOT NULL,
|
||||
start_time BIGINT NOT NULL,
|
||||
end_time BIGINT NULL,
|
||||
calendar_name TEXT NULL,
|
||||
misfire_instr SMALLINT NULL,
|
||||
misfire_orig_fire_time BIGINT NULL,
|
||||
execution_group VARCHAR(200) NULL,
|
||||
preferred_node VARCHAR(200) NULL,
|
||||
preferred_node_auto BOOL NOT NULL DEFAULT FALSE,
|
||||
job_data BYTEA NULL,
|
||||
PRIMARY KEY (sched_name, trigger_name, trigger_group),
|
||||
FOREIGN KEY (sched_name, job_name, job_group)
|
||||
REFERENCES qrtz_job_details (sched_name, job_name, job_group)
|
||||
);
|
||||
|
||||
CREATE TABLE qrtz_simple_triggers
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
trigger_name TEXT NOT NULL,
|
||||
trigger_group TEXT NOT NULL,
|
||||
repeat_count BIGINT NOT NULL,
|
||||
repeat_interval BIGINT NOT NULL,
|
||||
times_triggered BIGINT NOT NULL,
|
||||
PRIMARY KEY (sched_name, trigger_name, trigger_group),
|
||||
FOREIGN KEY (sched_name, trigger_name, trigger_group)
|
||||
REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE qrtz_simprop_triggers
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
trigger_name TEXT NOT NULL,
|
||||
trigger_group TEXT NOT NULL,
|
||||
str_prop_1 TEXT NULL,
|
||||
str_prop_2 TEXT NULL,
|
||||
str_prop_3 TEXT NULL,
|
||||
int_prop_1 INTEGER NULL,
|
||||
int_prop_2 INTEGER NULL,
|
||||
long_prop_1 BIGINT NULL,
|
||||
long_prop_2 BIGINT NULL,
|
||||
dec_prop_1 NUMERIC NULL,
|
||||
dec_prop_2 NUMERIC NULL,
|
||||
bool_prop_1 BOOL NULL,
|
||||
bool_prop_2 BOOL NULL,
|
||||
time_zone_id TEXT NULL,
|
||||
PRIMARY KEY (sched_name, trigger_name, trigger_group),
|
||||
FOREIGN KEY (sched_name, trigger_name, trigger_group)
|
||||
REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE qrtz_cron_triggers
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
trigger_name TEXT NOT NULL,
|
||||
trigger_group TEXT NOT NULL,
|
||||
cron_expression TEXT NOT NULL,
|
||||
time_zone_id TEXT,
|
||||
PRIMARY KEY (sched_name, trigger_name, trigger_group),
|
||||
FOREIGN KEY (sched_name, trigger_name, trigger_group)
|
||||
REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE qrtz_blob_triggers
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
trigger_name TEXT NOT NULL,
|
||||
trigger_group TEXT NOT NULL,
|
||||
blob_data BYTEA NULL,
|
||||
PRIMARY KEY (sched_name, trigger_name, trigger_group),
|
||||
FOREIGN KEY (sched_name, trigger_name, trigger_group)
|
||||
REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE qrtz_calendars
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
calendar_name TEXT NOT NULL,
|
||||
calendar BYTEA NOT NULL,
|
||||
PRIMARY KEY (sched_name, calendar_name)
|
||||
);
|
||||
|
||||
CREATE TABLE qrtz_paused_trigger_grps
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
trigger_group TEXT NOT NULL,
|
||||
PRIMARY KEY (sched_name, trigger_group)
|
||||
);
|
||||
|
||||
CREATE TABLE qrtz_fired_triggers
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
entry_id TEXT NOT NULL,
|
||||
trigger_name TEXT NOT NULL,
|
||||
trigger_group TEXT NOT NULL,
|
||||
instance_name TEXT NOT NULL,
|
||||
fired_time BIGINT NOT NULL,
|
||||
sched_time BIGINT NOT NULL,
|
||||
priority INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
job_name TEXT NULL,
|
||||
job_group TEXT NULL,
|
||||
is_nonconcurrent BOOL NOT NULL,
|
||||
requests_recovery BOOL NULL,
|
||||
execution_group VARCHAR(200) NULL,
|
||||
PRIMARY KEY (sched_name, entry_id)
|
||||
);
|
||||
|
||||
CREATE TABLE qrtz_scheduler_state
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
instance_name TEXT NOT NULL,
|
||||
last_checkin_time BIGINT NOT NULL,
|
||||
checkin_interval BIGINT NOT NULL,
|
||||
PRIMARY KEY (sched_name, instance_name)
|
||||
);
|
||||
|
||||
CREATE TABLE qrtz_locks
|
||||
(
|
||||
sched_name TEXT NOT NULL,
|
||||
lock_name TEXT NOT NULL,
|
||||
PRIMARY KEY (sched_name, lock_name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_qrtz_j_g_n ON qrtz_job_details (sched_name, job_group, job_name);
|
||||
CREATE INDEX idx_qrtz_t_j ON qrtz_triggers (sched_name, job_name, job_group);
|
||||
CREATE INDEX idx_qrtz_t_c ON qrtz_triggers (sched_name, calendar_name);
|
||||
CREATE INDEX idx_qrtz_t_g_n ON qrtz_triggers (sched_name, trigger_group, trigger_name);
|
||||
CREATE INDEX idx_qrtz_t_nft_st ON qrtz_triggers (sched_name, trigger_state, next_fire_time);
|
||||
CREATE INDEX idx_qrtz_ft_inst_job_req_rcvry ON qrtz_fired_triggers (sched_name, instance_name, requests_recovery);
|
||||
CREATE INDEX idx_qrtz_ft_j_g ON qrtz_fired_triggers (sched_name, job_name, job_group);
|
||||
CREATE INDEX idx_qrtz_ft_t_g ON qrtz_fired_triggers (sched_name, trigger_name, trigger_group);
|
||||
@@ -5,6 +5,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>c4131f8d-ff78-432e-86d2-f6e131bd4cd3</UserSecretsId>
|
||||
<AssemblyOriginatorKeyFile>..\PostFundManagement.snk</AssemblyOriginatorKeyFile>
|
||||
<SignAssembly>True</SignAssembly>
|
||||
<PackageLicenseFile>..\LICENSE</PackageLicenseFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Database -->
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,120 @@
|
||||
# PostFundManagement (PFM) Platform
|
||||
|
||||
An enterprise system designed to manage the entire post-funding grant and project lifecycle. PFM provides end-to-end oversight across strategic portfolios, programme allocation, project execution tracking, financial disbursements, monitoring & evaluation (M&E), and candidate notifications.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ System Architecture & Domain Model
|
||||
|
||||
The application follows a modular, hierarchical architecture where high-level funding directives cascade into measurable project outcomes and evidence verification.
|
||||
|
||||
[Portfolios] ───> [Programmes] ───> [Projects] ───> [Awards] ───> [Contracts] ───> [Beneficiaries] ──> [Outcomes]
|
||||
│ │ │
|
||||
▼ │ ▼
|
||||
[Rules] ├─> [Milestones] ──> [Activities] ──> [Evidences]
|
||||
├─> [Disbursements]
|
||||
├─> [Indicators]
|
||||
├─> [Risks] & [Issues]
|
||||
└─> [SiteVisits] & [ComplianceItems]
|
||||
|
||||
### **1. Portfolio & Programme Governance**
|
||||
* **Portfolios**: Top-level containers for strategic investment themes.
|
||||
* **Programmes**: Direct implementation vehicles linked to specific financial instruments (`Instruments`) and operational guidelines (`Rules`).
|
||||
|
||||
### **2. Project Execution & Monitoring**
|
||||
* **Projects**: Central operational units categorized by `IndustrySector` and `ProjectType`.
|
||||
* **Milestones & Activities**: Project progress is tracked through structured milestones. Project Managers author `Activities` against specific milestones to log ground work.
|
||||
* **Risks, Issues & Compliance**: Managed via dedicated tracking logs (`Risks`, `Issues`, `ComplianceItems`, `ChangeRequests`) to maintain project integrity.
|
||||
|
||||
### **3. Grants, Contracting & Disbursements**
|
||||
* **Awards & Contracts**: Binding grant agreements signed with target `Organisations`.
|
||||
* **Beneficiaries & Outcomes**: Social and economic impact measurement down to individual beneficiary groups.
|
||||
* **Disbursements**: Milestone-gated tranche releases tied to approved evidence and verification audits.
|
||||
|
||||
### **4. Monitoring, Evaluation & Evidence (M&E)**
|
||||
* **Indicators**: KPI baseline, target, and actual metrics tracking.
|
||||
* **Evidences**: Verifiable artifacts (`EvidenceType`) uploaded alongside activities, indicators, or milestones.
|
||||
* **Site Visits**: Independent audit logs (`SiteVisits`) submitted by field inspectors.
|
||||
|
||||
### **5. Notifications & Platform Services**
|
||||
* Multi-channel delivery engine (`NotificationPlatform`) tracking candidate invitations, reminders, and status updates (`NotificationStatus`) across Email, SMS, WhatsApp, In-App, and Webhooks.
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Key System Enums
|
||||
|
||||
| Enum | Scope | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| `IndustrySector` | Project Domain | Standardizes reporting across sectors (e.g., ICT, Agriculture, Manufacturing). |
|
||||
| `ProjectType` | Project Domain | Classifies project scope (e.g., R&D, Infrastructure, Capacity Building). |
|
||||
| `EvidenceType` | M&E / Verification | Categorizes uploaded proof (e.g., Attendance Register, Geotagged Photo, Bank Statement). |
|
||||
| `NotificationPlatform` | System / Comms | Outlines dispatch channels (Email, SMS, WhatsApp, Push, Webhooks). |
|
||||
| `NotificationStatus` | System / Comms | Tracks communication lifecycle (Pending, Queued, Sent, Delivered, Failed). |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tech Stack & Patterns
|
||||
|
||||
* **Backend Framework**: .NET 8 / 9 C# Web API
|
||||
* **Database**: PostgreSQL
|
||||
* **ORM**: Entity Framework Core (Fluent API Configuration)
|
||||
* **Architecture**: Clean Architecture / Domain-Driven Design (DDD) with CQRS (`MediatR`)
|
||||
* **Utilities**: `Humanizer` (Automated entity-to-table pluralization)
|
||||
|
||||
---
|
||||
|
||||
## 📁 Solution Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── PostFundManagement.Domain/ # Core Domain Entities, Enums & Interfaces
|
||||
├── PostFundManagement.Infrastructure/ # EF Core Configurations, DbContext, Migrations
|
||||
├── PostFundManagement.Application/ # CQRS Handlers, DTOs, Business Rules & Logic
|
||||
└── PostFundManagement.Api/ # REST Controllers, Middleware, Auth & Swagger
|
||||
```
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
.NET 8.0 SDK or higher
|
||||
PostgreSQL 14+
|
||||
dotnet-ef CLI Tool (dotnet tool install --global dotnet-ef)
|
||||
|
||||
## Setup & Execution
|
||||
|
||||
## Clone Repository
|
||||
|
||||
1. Clone Repository:
|
||||
|
||||
```bash
|
||||
git clone [https://github.com/your-org/PostFundManagement.git](https://github.com/your-org/PostFundManagement.git)
|
||||
cd PostFundManagement
|
||||
```
|
||||
|
||||
2. Configure Database Connection:
|
||||
Update appsettings.Development.json in the API project with your local PostgreSQL details:
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=pfm_db;Username=postgres;Password=your_password"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Apply Database Migrations:
|
||||
|
||||
```bash
|
||||
dotnet ef database update --project src/PostFundManagement.Infrastructure
|
||||
```
|
||||
|
||||
4. Launch Application:
|
||||
|
||||
```bash
|
||||
dotnet run --project src/PostFundManagement.Api
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
Internal Proprietary Software — All rights reserved.
|
||||
Reference in New Issue
Block a user