First commit. Include junk from template to remove

This commit is contained in:
Dominic Villemure
2024-03-09 20:25:30 -05:00
commit bbcefcf76f
140 changed files with 8151 additions and 0 deletions

362
.azure/bicep/main.bicep Normal file
View File

@@ -0,0 +1,362 @@
@description('The location into which your Azure resources should be deployed.')
param location string = resourceGroup().location
@description('Select the type of environment you want to provision. Allowed values are Production, Staging, and Development.')
@allowed([
'Production'
'Staging'
'Development'
])
param environmentName string
@description('A unique suffix to add to resource names that need to be globally unique.')
@maxLength(13)
param resourceNameSuffix string = uniqueString(resourceGroup().id)
@description('The administrator login username for the SQL server.')
param sqlAdministratorUsername string
@secure()
@description('The administrator login password for the SQL server.')
param sqlAdministratorPassword string
@description('The name of the project.')
param projectName string
// Define the environment configuration map.
var environmentConfigurationMap = {
Production: {
environmentAbbreviation: 'prd'
appServicePlan: {
sku: {
name: 'S1'
capacity: 1
}
}
sqlDatabase: {
sku: {
name: 'Standard'
tier: 'Standard'
}
}
}
Staging: {
environmentAbbreviation: 'stg'
appServicePlan: {
sku: {
name: 'B1'
}
}
sqlDatabase: {
sku: {
name: 'Standard'
tier: 'Standard'
}
}
}
Development: {
environmentAbbreviation: 'dev'
appServicePlan: {
sku: {
name: 'B1'
}
}
sqlDatabase: {
sku: {
name: 'Standard'
tier: 'Standard'
}
}
}
}
// Define the names for resources.
var environmentAbbreviation = environmentConfigurationMap[environmentName].environmentAbbreviation
var keyVaultName = 'kv-${projectName}-${environmentAbbreviation}'
var appServiceAppName = 'as-${projectName}-${resourceNameSuffix}-${environmentAbbreviation}'
var appServicePlanName = 'plan-${projectName}-${environmentAbbreviation}'
var logAnalyticsWorkspaceName = 'log-${projectName}-${environmentAbbreviation}'
var applicationInsightsName = 'appi-${projectName}-${environmentAbbreviation}'
var sqlServerName = 'sql-${projectName}-${resourceNameSuffix}-${environmentAbbreviation}'
var sqlDatabaseName = '${projectName}-${environmentAbbreviation}'
// Define the SKUs for each component based on the environment type.
var appServicePlanSku = environmentConfigurationMap[environmentName].appServicePlan.sku
var sqlDatabaseSku = environmentConfigurationMap[environmentName].sqlDatabase.sku
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-10-01' = {
name: logAnalyticsWorkspaceName
location: location
properties: {
sku: {
name: 'PerGB2018'
}
}
}
resource keyVault 'Microsoft.KeyVault/vaults@2019-09-01' = {
name: keyVaultName
location: location
properties: {
enabledForTemplateDeployment: true
tenantId: subscription().tenantId
accessPolicies: []
sku: {
name: 'standard'
family: 'A'
}
}
}
resource keyVault_ConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2019-09-01' = {
parent: keyVault
name: 'ConnectionStrings--DefaultConnection'
properties: {
value: 'Server=tcp:${sqlServer.properties.fullyQualifiedDomainName},1433;Initial Catalog=${sqlDatabaseName};Persist Security Info=False;User ID=${sqlAdministratorUsername};Password=${sqlAdministratorPassword};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;'
}
dependsOn: [
sqlDatabase
]
}
resource keyVault_DiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
scope: keyVault
name: 'keyVaultDiagnosticSettings'
properties: {
workspaceId: logAnalyticsWorkspace.id
logs: [
{
category: 'AuditEvent'
enabled: true
}
]
metrics: [
{
category: 'AllMetrics'
enabled: true
}
]
}
}
resource appServicePlan 'Microsoft.Web/serverfarms@2021-01-15' = {
name: appServicePlanName
location: location
sku: appServicePlanSku
}
resource appServicePlan_DiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
scope: appServicePlan
name: 'appServicePlanDiagnosticSettings'
properties: {
workspaceId: logAnalyticsWorkspace.id
metrics: [
{
category: 'AllMetrics'
enabled: true
}
]
}
}
resource appServiceApp 'Microsoft.Web/sites@2021-01-15' = {
name: appServiceAppName
location: location
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: appServicePlan.id
httpsOnly: true
siteConfig: {
healthCheckPath: '/health'
netFrameworkVersion: 'v7.0'
appSettings: [
{
name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
value: applicationInsights.properties.ConnectionString
}
{
name: 'KeyVaultUri'
value: keyVault.properties.vaultUri
}
]
}
}
}
resource appServiceApp_DiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
scope: appServiceApp
name: 'appServiceAppDiagnosticSettings'
properties: {
workspaceId: logAnalyticsWorkspace.id
logs: [
{
category: 'AppServiceHTTPLogs'
enabled: true
}
{
category: 'AppServiceConsoleLogs'
enabled: true
}
{
category: 'AppServiceAppLogs'
enabled: true
}
{
category: 'AppServiceAuditLogs'
enabled: true
}
{
category: 'AppServiceIPSecAuditLogs'
enabled: true
}
{
category: 'AppServicePlatformLogs'
enabled: true
}
]
metrics: [
{
category: 'AllMetrics'
enabled: true
}
]
}
}
resource keyVault_AccessPolicy 'Microsoft.KeyVault/vaults/accessPolicies@2022-07-01' = {
parent: keyVault
name: 'add'
properties: {
accessPolicies: [
{
tenantId: appServiceApp.identity.tenantId
objectId: appServiceApp.identity.principalId
permissions: {
keys: [
'Get'
]
secrets: [
'Get'
'List'
]
certificates: [
'Get'
'List'
]
}
}
]
}
}
resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = {
name: applicationInsightsName
location: location
kind: 'web'
properties: {
Application_Type: 'web'
WorkspaceResourceId: logAnalyticsWorkspace.id
}
}
resource sqlServer 'Microsoft.Sql/servers@2021-02-01-preview' = {
name: sqlServerName
location: location
properties: {
administratorLogin: sqlAdministratorUsername
administratorLoginPassword: sqlAdministratorPassword
}
}
resource sqlDatabase 'Microsoft.Sql/servers/databases@2021-02-01-preview' = {
parent: sqlServer
name: sqlDatabaseName
location: location
sku: sqlDatabaseSku
}
resource sqlServer_AuditingSettings 'Microsoft.Sql/servers/auditingSettings@2021-11-01-preview' = {
parent: sqlServer
name: 'default'
properties: {
state: 'Enabled'
isAzureMonitorTargetEnabled: true
}
}
resource sqlServer_FirewallRule 'Microsoft.Sql/servers/firewallRules@2021-02-01-preview' = {
parent: sqlServer
name: 'AllowAllWindowsAzureIps'
properties: {
endIpAddress: '0.0.0.0'
startIpAddress: '0.0.0.0'
}
}
resource sqlDatabase_DiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
scope: sqlDatabase
name: 'sqlDatabaseDiagnosticSettings'
properties: {
workspaceId: logAnalyticsWorkspace.id
logs: [
{
category: 'SQLInsights'
enabled: true
}
{
category: 'AutomaticTuning'
enabled: true
}
{
category: 'QueryStoreRuntimeStatistics'
enabled: true
}
{
category: 'QueryStoreWaitStatistics'
enabled: true
}
{
category: 'Errors'
enabled: true
}
{
category: 'DatabaseWaitStatistics'
enabled: true
}
{
category: 'Timeouts'
enabled: true
}
{
category: 'Blocks'
enabled: true
}
{
category: 'Deadlocks'
enabled: true
}
]
metrics: [
{
category: 'Basic'
enabled: true
}
{
category: 'InstanceAndAppAdvanced'
enabled: true
}
{
category: 'WorkloadManagement'
enabled: true
}
]
}
}
output appServiceAppName string = appServiceApp.name
output appServiceAppHostName string = appServiceApp.properties.defaultHostName
output sqlServerFullyQualifiedDomainName string = sqlServer.properties.fullyQualifiedDomainName
output sqlDatabaseName string = sqlDatabase.name

382
.editorconfig Normal file
View File

@@ -0,0 +1,382 @@
root = true
# All files
[*]
indent_style = space
# Xml files
[*.{xml,csproj,props,targets,ruleset,nuspec,resx}]
indent_size = 2
# Javascript files
[*.js]
indent_size = 2
# Json files
[*.{json,config,nswag}]
indent_size = 2
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
tab_width = 4
# New line preferences
end_of_line = lf
insert_final_newline = true
#### .NET Coding Conventions ####
[*.{cs,vb}]
# Organize usings
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = true
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = false:silent
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_method = false:silent
dotnet_style_qualification_for_property = false:silent
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_predefined_type_for_member_access = true:silent
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
# Expression-level preferences
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
# Field preferences
dotnet_style_readonly_field = true:warning
# Parameter preferences
dotnet_code_quality_unused_parameters = all:suggestion
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
#### C# Coding Conventions ####
[*.cs]
# var preferences
csharp_style_var_elsewhere = false:silent
csharp_style_var_for_built_in_types = false:silent
csharp_style_var_when_type_is_apparent = false:silent
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:suggestion
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_pattern_matching = true:silent
csharp_style_prefer_switch_expression = true:suggestion
# Null-checking preferences
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences
csharp_prefer_static_local_function = true:warning
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_pattern_local_over_anonymous_function = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:silent
#### 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
csharp_style_namespace_declarations = file_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
#### Naming styles ####
[*.{cs,vb}]
# Naming rules
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.events_should_be_pascalcase.symbols = events
dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
# Symbol specifications
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interfaces.required_modifiers =
dotnet_naming_symbols.enums.applicable_kinds = enum
dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.enums.required_modifiers =
dotnet_naming_symbols.events.applicable_kinds = event
dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.events.required_modifiers =
dotnet_naming_symbols.methods.applicable_kinds = method
dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.methods.required_modifiers =
dotnet_naming_symbols.properties.applicable_kinds = property
dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.properties.required_modifiers =
dotnet_naming_symbols.public_fields.applicable_kinds = field
dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_fields.required_modifiers =
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_fields.required_modifiers = static
dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types_and_namespaces.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 =
dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
dotnet_naming_symbols.type_parameters.required_modifiers =
dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_constant_fields.required_modifiers = const
dotnet_naming_symbols.local_variables.applicable_kinds = local
dotnet_naming_symbols.local_variables.applicable_accessibilities = local
dotnet_naming_symbols.local_variables.required_modifiers =
dotnet_naming_symbols.local_constants.applicable_kinds = local
dotnet_naming_symbols.local_constants.applicable_accessibilities = local
dotnet_naming_symbols.local_constants.required_modifiers = const
dotnet_naming_symbols.parameters.applicable_kinds = parameter
dotnet_naming_symbols.parameters.applicable_accessibilities = *
dotnet_naming_symbols.parameters.required_modifiers =
dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_constant_fields.required_modifiers = const
dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
dotnet_naming_symbols.local_functions.applicable_accessibilities = *
dotnet_naming_symbols.local_functions.required_modifiers =
# Naming styles
dotnet_naming_style.pascalcase.required_prefix =
dotnet_naming_style.pascalcase.required_suffix =
dotnet_naming_style.pascalcase.word_separator =
dotnet_naming_style.pascalcase.capitalization = pascal_case
dotnet_naming_style.ipascalcase.required_prefix = I
dotnet_naming_style.ipascalcase.required_suffix =
dotnet_naming_style.ipascalcase.word_separator =
dotnet_naming_style.ipascalcase.capitalization = pascal_case
dotnet_naming_style.tpascalcase.required_prefix = T
dotnet_naming_style.tpascalcase.required_suffix =
dotnet_naming_style.tpascalcase.word_separator =
dotnet_naming_style.tpascalcase.capitalization = pascal_case
dotnet_naming_style._camelcase.required_prefix = _
dotnet_naming_style._camelcase.required_suffix =
dotnet_naming_style._camelcase.word_separator =
dotnet_naming_style._camelcase.capitalization = camel_case
dotnet_naming_style.camelcase.required_prefix =
dotnet_naming_style.camelcase.required_suffix =
dotnet_naming_style.camelcase.word_separator =
dotnet_naming_style.camelcase.capitalization = camel_case
dotnet_naming_style.s_camelcase.required_prefix = s_
dotnet_naming_style.s_camelcase.required_suffix =
dotnet_naming_style.s_camelcase.word_separator =
dotnet_naming_style.s_camelcase.capitalization = camel_case
dotnet_style_namespace_match_folder = true:suggestion

13
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: JasonTaylorDev # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
# patreon: # Replace with a single Patreon username
# open_collective: # Replace with a single Open Collective username
# ko_fi: # Replace with a single Ko-fi username
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
# liberapay: # Replace with a single Liberapay username
# issuehunt: # Replace with a single IssueHunt username
# otechie: # Replace with a single Otechie username
# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

82
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
name: Build
on:
pull_request:
branches: [ main ]
paths-ignore:
- '.scripts/**'
- .gitignore
- CODE_OF_CONDUCT.md
- LICENSE
- README.md
workflow_call:
inputs:
build-artifacts:
type: boolean
required: true
default: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
name: Checkout code
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Install .NET
uses: actions/setup-dotnet@v3
- name: Restore solution
run: dotnet restore
- name: Build solution
run: dotnet build --no-restore --configuration Release
- name: Test solution
run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName!~AcceptanceTests"
- name: Publish website
if: ${{ inputs.build-artifacts == true }}
run: |
dotnet publish --configuration Release --runtime win-x86 --self-contained --output ./publish
cd publish
zip -r ./publish.zip .
working-directory: ./src/Web/
- name: Upload website artifact (website)
if: ${{ inputs.build-artifacts == true }}
uses: actions/upload-artifact@v3
with:
name: website
path: ./src/Web/publish/publish.zip
if-no-files-found: error
- name: Create EF Core migrations bundle
if: ${{ inputs.build-artifacts == true }}
run: |
dotnet new tool-manifest
dotnet tool install dotnet-ef
dotnet ef migrations bundle --configuration Release -p ./src/Infrastructure/ -s ./src/Web/ -o efbundle.exe
zip -r ./efbundle.zip efbundle.exe
env:
SkipNSwag: True
- name: Upload EF Core migrations bundle artifact (efbundle)
if: ${{ inputs.build-artifacts == true }}
uses: actions/upload-artifact@v3
with:
name: efbundle
path: ./efbundle.zip
if-no-files-found: error

42
.github/workflows/cicd.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: CICD
on:
push:
branches: [ main ]
paths-ignore:
- .gitignore
- CODE_OF_CONDUCT.md
- LICENSE
- README.md
permissions:
id-token: write
contents: read
jobs:
build:
uses: ./.github/workflows/build.yml
with:
build-artifacts: true
deploy-development:
uses: ./.github/workflows/deploy.yml
secrets: inherit
needs: [ build ]
with:
environmentName: Development
deploy-staging:
uses: ./.github/workflows/deploy.yml
secrets: inherit
needs: [ deploy-development ]
with:
environmentName: Staging
deploy-production:
uses: ./.github/workflows/deploy.yml
secrets: inherit
needs: [ deploy-staging ]
with:
environmentName: Production

107
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,107 @@
name: Deploy
on:
workflow_call:
inputs:
environmentName:
required: true
type: string
permissions:
id-token: write
contents: read
jobs:
validate:
runs-on: ubuntu-latest
environment: ${{ inputs.environmentName }}
steps:
- uses: actions/checkout@v3
name: Checkout code
- uses: azure/login@v1
name: Login to Azure
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- if: inputs.environmentName == 'Development'
uses: azure/arm-deploy@v1
name: Run preflight validation
with:
deploymentName: ${{ github.run_number }}
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
template: ./.azure/bicep/main.bicep
parameters: >
environmentName=${{ inputs.environmentName }}
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
projectName=${{ vars.PROJECT_NAME }}
deploymentMode: Validate
- if: inputs.environmentName != 'Development'
uses: azure/arm-deploy@v1
name: Run what-if
with:
failOnStdErr: false
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
template: ./.azure/bicep/main.bicep
parameters: >
environmentName=${{ inputs.environmentName }}
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
projectName=${{ vars.PROJECT_NAME }}
additionalArguments: --what-if
deploy:
needs: [ validate ]
runs-on: ubuntu-latest
environment: ${{ inputs.environmentName }}
steps:
- uses: actions/checkout@v3
name: Checkout code
- uses: actions/download-artifact@v3
name: Download artifacts
- name: Install .NET
uses: actions/setup-dotnet@v3
- uses: azure/login@v1
name: Login to Azure
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- uses: azure/arm-deploy@v1
id: deploy
name: Deploy infrastructure
with:
failOnStdErr: false
deploymentName: ${{ github.run_number }}
resourceGroupName: ${{ vars.AZURE_RESOURCE_GROUP_NAME }}
template: ./.azure/bicep/main.bicep
parameters: >
environmentName=${{ inputs.environmentName }}
sqlAdministratorUsername=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }}
sqlAdministratorPassword=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }}
projectName=${{ vars.PROJECT_NAME }}
- name: Initialise database
run: |
unzip -o ./efbundle/efbundle.zip
echo '{ "ConnectionStrings": { "DefaultConnection": "" } }' > appsettings.json
./efbundle.exe --connection "Server=${{ steps.deploy.outputs.sqlServerFullyQualifiedDomainName }};Initial Catalog=${{ steps.deploy.outputs.sqlDatabaseName }};Persist Security Info=False;User ID=${{ vars.AZURE_SQL_ADMINISTRATOR_USERNAME }};Password=${{ secrets.AZURE_SQL_ADMINISTRATOR_PASSWORD }};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" --verbose
- uses: azure/webapps-deploy@v2
name: Deploy website
with:
app-name: ${{ steps.deploy.outputs.appServiceAppName }}
package: website/publish.zip

480
.gitignore vendored Normal file
View File

@@ -0,0 +1,480 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

29
.scripts/checks.ps1 Normal file
View File

@@ -0,0 +1,29 @@
# Check if Azure CLI is installed
$azPath = (Get-Command az -ErrorAction SilentlyContinue).Source
if (-not $azPath) {
throw "Azure CLI (az) is not installed. Please install it and try again."
}
# Check if Azure CLI is authenticated
az account show --output none
if ($LASTEXITCODE -ne 0) {
throw "Azure CLI (az) is not authenticated. Please authenticate with Azure CLI and try again."
}
# Check if GitHub CLI is installed
$ghPath = (Get-Command gh -ErrorAction SilentlyContinue).Source
if (-not $ghPath) {
throw "GitHub CLI (gh) is not installed. Please install it and try again."
}
# Check if GitHub CLI is authenticated
gh auth status | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "GitHub CLI (gh) is not authenticated. Please authenticate with GitHub CLI and try again."
}
# Check if Git repo is initialised
git status | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "The Git repository has not been initialised. Please create a new GitHub repository and try again."
}

78
.scripts/cleanup.ps1 Normal file
View File

@@ -0,0 +1,78 @@
Param(
[String]$ProjectName
)
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$checksScript = Join-Path $scriptRoot "checks.ps1"
try {
. $checksScript
} catch {
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host "Setup script terminated due to the checks failure." -ForegroundColor Red
exit 1
}
$MissingParameterValues = $false
if (-not $ProjectName) {
$ProjectName = $(gh repo view --json name -q '.name' 2> $null)
if (-not $ProjectName) { $MissingParameterValues = $true }
}
$ScriptParameters = @{
"ProjectName" = $ProjectName
}
Write-Host
Write-Host "This script performs cleanup operations to delete resource groups, Azure AD applications, and purge deleted key vaults associated with a specific project hosted on GitHub. It searches for resources based on the project name and performs the necessary deletion and purging actions. The script leverages the Azure CLI to interact with Azure resources. It aims to facilitate the cleanup process and remove unnecessary resources from your Azure environment."
Write-Host
Write-Host "Parameters:" -ForegroundColor Green
$ScriptParameters | Format-Table -AutoSize
Write-Host
if ($MissingParameterValues) {
Write-Host "Script execution cancelled. Missing parameter values." -ForegroundColor Red
return
}
Write-Host "Warning: This script will perform cleanup operations, including deleting resource groups, Azure AD applications, and purging deleted key vaults starting with the project name '$ProjectName'. Make sure you understand the consequences and have verified the project name before proceeding." -ForegroundColor Red
Write-Host
Write-Host "Disclaimer: Use this script at your own risk. The author and contributors are not responsible for any loss of data or unintended consequences resulting from running this script." -ForegroundColor Yellow
Write-Host
$confirmation = Read-Host "Do you want to continue? (y/N)"
if ($confirmation -ne "y") {
Write-Host "Script execution cancelled." -ForegroundColor Red
return
}
Write-Host "🔍 Searching for Resource Groups..."
$resourceGroups = az group list --query "[?starts_with(name, '$ProjectName')].name" --output tsv
foreach ($rg in $resourceGroups) {
Write-Host "🔥 Deleting: $rg"
az group delete --name $rg --yes > $null 2>&1
}
Write-Host "🔍 Searching for Azure AD Applications..."
$appRegistrations = az ad app list --display-name $ProjectName --query "[].{Name:displayName, AppId:appId}" --output json | ConvertFrom-Json
foreach ($appRegistration in $appRegistrations) {
$appName = $appRegistration.Name
$appId = $appRegistration.AppId
Write-Host "🔥 Deleting: $appName"
az ad app delete --id $appId > $null 2>&1
}
Write-Host "🔍 Searching for Deleted Key Vaults..."
$deletedKeyVaults = az keyvault list-deleted --query "[?starts_with(name, 'kv-$ProjectName')].name" --output tsv
foreach ($vaultName in $deletedKeyVaults) {
Write-Host "🔥 Purging: $vaultName"
az keyvault purge --name $vaultName > $null 2>&1
}
Write-Host "✅ Done"

View File

@@ -0,0 +1,5 @@
{
"Dev": "Development",
"Stg": "Staging",
"Prd": "Production"
}

210
.scripts/setup.ps1 Normal file
View File

@@ -0,0 +1,210 @@
Param(
[String]$GitHubOrganisationName,
[String]$GitHubRepositoryName,
[String]$AzureLocation,
[String]$AzureSubscriptionId,
[String]$AzureTenantId,
[ValidateLength(4, 17)]
[String]$ProjectName,
[String]$AzureSqlLogin = "SqlAdmin"
)
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$checksScript = Join-Path $scriptRoot "checks.ps1"
$environmentsFile = Join-Path $scriptRoot "environments.json"
try {
. $checksScript
} catch {
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host "Setup script terminated due to the checks failure." -ForegroundColor Red
exit 1
}
$MissingParameterValues = $false
if (-not $GitHubOrganisationName) {
$ownerJson = gh repo view --json owner 2>$null | ConvertFrom-Json
if ($ownerJson -and $ownerJson.owner -and $ownerJson.owner.login) {
$GitHubOrganisationName = $ownerJson.owner.login
}
else {
$MissingParameterValues = $true
}
}
if (-not $GitHubRepositoryName) {
$GitHubRepositoryName = $(gh repo view --json name -q '.name' 2> $null)
if (-not $GitHubRepositoryName) { $MissingParameterValues = $true }
}
if (-not $AzureLocation) {
$AzureLocation = "australiaeast"
}
if (-not $AzureSubscriptionId) {
$AzureSubscriptionId = $(az account show --query id --output tsv 2> $null)
if (-not $AzureSubscriptionId) { $MissingParameterValues = $true }
}
if (-not $AzureTenantId) {
$AzureTenantId = $(az account show --query tenantId --output tsv 2> $null)
if (-not $AzureTenantId) { $MissingParameterValues = $true }
}
if (-not $ProjectName) {
if ($GitHubRepositoryName) {
$ProjectName = $GitHubRepositoryName
}
if (-not $ProjectName) { $MissingParameterValues = $true }
}
$repoUrl = "https://github.com/$GitHubOrganisationName/$GitHubRepositoryName"
$environments = Get-Content -Raw -Path $environmentsFile | ConvertFrom-Json
$ParametersTableData = @{
"AzureLocation" = $AzureLocation
"AzureSubscriptionID" = $AzureSubscriptionId
"AzureTenantID" = $AzureTenantId
"GitHubOrganisationName" = $GitHubOrganisationName
"GitHubRepositoryName" = $GitHubRepositoryName
"ProjectName" = $ProjectName
"AzureSqlLogin" = $AzureSqlLogin
}
Write-Host
Write-Host "This script automates the setup of environments, resources, and credentials for a project hosted on GitHub and deployed to Azure. It creates workload identities in Azure AD, sets up resource groups, and configures environment-specific variables and secrets in the GitHub repository. The script leverages the Azure CLI, GitHub CLI, and GitHub APIs to perform these tasks. It aims to streamline the process of setting up and configuring development, staging, and production environments for the project."
Write-Host
Write-Host "Parameters:" -ForegroundColor Green
$ParametersTableData | Format-Table -AutoSize
if ($MissingParameterValues) {
Write-Host "Script execution cancelled. Missing parameter values." -ForegroundColor Red
exit 1
}
$EnvironmentTableData = foreach ($environment in $environments.PSObject.Properties) {
[PSCustomObject]@{
Abbreviation = $environment.Name
Name = $environment.Value
}
}
Write-Host "Environments:" -ForegroundColor Green
$EnvironmentTableData | Select-Object Name, Abbreviation | Format-Table -AutoSize
Write-Host
Write-Host "Warning: Running this script will perform various operations in your GitHub repository and Azure subscription. Ensure that you have the necessary permissions and understand the consequences. " -ForegroundColor Red
Write-Host
Write-Host "Disclaimer: Use this script at your own risk. The author and contributors are not responsible for any loss of data or unintended consequences resulting from running this script." -ForegroundColor Yellow
Write-Host
$confirmation = Read-Host "Do you want to continue? (y/N)"
if ($confirmation -ne "y") {
Write-Host "Script execution cancelled." -ForegroundColor Red
return
}
Write-Host
function CreateWorkloadIdentity {
param (
$environmentAbbr,
$environmentName
)
# Create Azure AD Application Registration
$applicationRegistrationDetails=$(az ad app create --display-name "$ProjectName$environmentAbbr") | ConvertFrom-Json
# Create federated credentials
$credential = @{
name="$ProjectName$environmentName";
issuer="https://token.actions.githubusercontent.com";
subject="repo:${GitHubOrganisationName}/${GitHubRepositoryName}:environment:$environmentName";
audiences=@("api://AzureADTokenExchange")
} | ConvertTo-Json
$credential | az ad app federated-credential create --id $applicationRegistrationDetails.id --parameters "@-" | Out-Null
$credential = @{
name="$ProjectName";
issuer="https://token.actions.githubusercontent.com";
subject="repo:${GitHubOrganisationName}/${GitHubRepositoryName}:ref:refs/heads/main";
audiences=@("api://AzureADTokenExchange")
} | ConvertTo-Json
$credential | az ad app federated-credential create --id $applicationRegistrationDetails.id --parameters "@-" | Out-Null
return $applicationRegistrationDetails.appId
}
function CreateResourceGroup {
param (
$environmentAbbr,
$appId
)
$resourceGroupId = $(az group create --name "$ProjectName$environmentAbbr" --location $AzureLocation --query id --output tsv)
az ad sp create --id $appId
az role assignment create --assignee $appId --role Contributor --scope $resourceGroupId
}
function CreateEnvironment {
param (
$environmentName
)
$token = gh auth token
$header = @{"Authorization" = "token $token" }
$contentType = "application/json"
$uri = "https://api.github.com/repos/$GitHubOrganisationName/$GitHubRepositoryName/environments/$environmentName"
Invoke-WebRequest -Method PUT -Header $header -ContentType $contentType -Uri $uri
}
function GenerateRandomPassword {
param (
[int]$Length = 16
)
$ValidChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#^_-+=?<>|~".ToCharArray()
$Password = -join ((Get-Random -Count $Length -InputObject $ValidChars) | Get-Random -Count $Length)
return $Password
}
function SetVariables() {
gh variable set AZURE_TENANT_ID --body $AzureTenantId --repo $repoUrl
gh variable set AZURE_SUBSCRIPTION_ID --body $AzureSubscriptionId --repo $repoUrl
gh variable set PROJECT_NAME --body $ProjectName --repo $repoUrl
}
function SetEnvironmentVariablesAndSecrets {
param(
$environmentAbbr,
$environmentName,
$appId
)
gh variable set AZURE_CLIENT_ID --body "$appId" --env $environmentName --repo $repoUrl
gh variable set AZURE_RESOURCE_GROUP_NAME --body "$ProjectName$environmentAbbr" --env $environmentName --repo $repoUrl
gh variable set AZURE_SQL_ADMINISTRATOR_USERNAME --body "$AzureSqlLogin" --env $environmentName --repo $repoUrl
gh secret set AZURE_SQL_ADMINISTRATOR_PASSWORD --body (GenerateRandomPassword) --env $environmentName --repo $repoUrl
}
SetVariables
foreach ($environment in $environments.PSObject.Properties) {
$environmentAbbr = $environment.Name
$environmentName = $environment.Value
CreateEnvironment $environmentName
$appId = CreateWorkloadIdentity $environmentAbbr $environmentName
CreateResourceGroup $environmentAbbr $appId
SetEnvironmentVariablesAndSecrets $environmentAbbr $environmentName $appId
}
Write-Host "✅ Done"

10
Directory.Build.props Normal file
View File

@@ -0,0 +1,10 @@
<!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build -->
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

38
Directory.Packages.props Normal file
View File

@@ -0,0 +1,38 @@
<!-- For more info on central package management go to https://devblogs.microsoft.com/nuget/introducing-central-package-management/ -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Ardalis.GuardClauses" Version="4.2.0" />
<PackageVersion Include="AutoMapper" Version="13.0.1" />
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.0" />
<PackageVersion Include="Azure.Identity" Version="1.10.4" />
<PackageVersion Include="coverlet.collector" Version="6.0.0" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.8.1" />
<PackageVersion Include="MediatR" Version="12.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageVersion Include="Moq" Version="4.20.69" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.0.3" />
<PackageVersion Include="NSwag.MSBuild" Version="14.0.3" />
<PackageVersion Include="nunit" Version="3.14.0" />
<PackageVersion Include="NUnit.Analyzers" Version="3.9.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageVersion Include="Respawn" Version="6.1.0" />
<PackageVersion Include="Testcontainers.MsSql" Version="3.6.0" />
</ItemGroup>
</Project>

91
Hutopy.sln Normal file
View File

@@ -0,0 +1,91 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "src\Domain\Domain.csproj", "{C7E89A3E-A631-4760-8D61-BD1EAB1C4E69}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application", "src\Application\Application.csproj", "{34C0FACD-F3D9-400C-8945-554DD6B0819A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{117DA02F-5274-4565-ACC6-DA9B6E568B09}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6ED356A7-8B47-4613-AD01-C85CF28491BD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{664D406C-2F83-48F0-BFC3-408D5CB53C65}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application.UnitTests", "tests\Application.UnitTests\Application.UnitTests.csproj", "{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain.UnitTests", "tests\Domain.UnitTests\Domain.UnitTests.csproj", "{DC37FD87-552C-4613-9F16-1537CA522898}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E2DA20AA-28D1-455C-BF50-C49A8F831633}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.gitignore = .gitignore
Directory.Build.props = Directory.Build.props
Directory.Packages.props = Directory.Packages.props
global.json = global.json
README.md = README.md
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Web", "src\Web\Web.csproj", "{4E4EE20C-F06A-4A1B-851F-C5577796941C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application.FunctionalTests", "tests\Application.FunctionalTests\Application.FunctionalTests.csproj", "{EA6127A5-94C9-4C31-AD11-E6811B92B520}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.IntegrationTests", "tests\Infrastructure.IntegrationTests\Infrastructure.IntegrationTests.csproj", "{01FA6786-921D-4CE8-8C50-4FDA66C9477D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C7E89A3E-A631-4760-8D61-BD1EAB1C4E69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C7E89A3E-A631-4760-8D61-BD1EAB1C4E69}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C7E89A3E-A631-4760-8D61-BD1EAB1C4E69}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C7E89A3E-A631-4760-8D61-BD1EAB1C4E69}.Release|Any CPU.Build.0 = Release|Any CPU
{34C0FACD-F3D9-400C-8945-554DD6B0819A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{34C0FACD-F3D9-400C-8945-554DD6B0819A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{34C0FACD-F3D9-400C-8945-554DD6B0819A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{34C0FACD-F3D9-400C-8945-554DD6B0819A}.Release|Any CPU.Build.0 = Release|Any CPU
{117DA02F-5274-4565-ACC6-DA9B6E568B09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{117DA02F-5274-4565-ACC6-DA9B6E568B09}.Debug|Any CPU.Build.0 = Debug|Any CPU
{117DA02F-5274-4565-ACC6-DA9B6E568B09}.Release|Any CPU.ActiveCfg = Release|Any CPU
{117DA02F-5274-4565-ACC6-DA9B6E568B09}.Release|Any CPU.Build.0 = Release|Any CPU
{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DEFF4009-1FAB-4392-80B6-707E2DC5C00B}.Release|Any CPU.Build.0 = Release|Any CPU
{DC37FD87-552C-4613-9F16-1537CA522898}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC37FD87-552C-4613-9F16-1537CA522898}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC37FD87-552C-4613-9F16-1537CA522898}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC37FD87-552C-4613-9F16-1537CA522898}.Release|Any CPU.Build.0 = Release|Any CPU
{4E4EE20C-F06A-4A1B-851F-C5577796941C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4E4EE20C-F06A-4A1B-851F-C5577796941C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4E4EE20C-F06A-4A1B-851F-C5577796941C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4E4EE20C-F06A-4A1B-851F-C5577796941C}.Release|Any CPU.Build.0 = Release|Any CPU
{EA6127A5-94C9-4C31-AD11-E6811B92B520}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EA6127A5-94C9-4C31-AD11-E6811B92B520}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EA6127A5-94C9-4C31-AD11-E6811B92B520}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EA6127A5-94C9-4C31-AD11-E6811B92B520}.Release|Any CPU.Build.0 = Release|Any CPU
{01FA6786-921D-4CE8-8C50-4FDA66C9477D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{01FA6786-921D-4CE8-8C50-4FDA66C9477D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{01FA6786-921D-4CE8-8C50-4FDA66C9477D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{01FA6786-921D-4CE8-8C50-4FDA66C9477D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C7E89A3E-A631-4760-8D61-BD1EAB1C4E69} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
{34C0FACD-F3D9-400C-8945-554DD6B0819A} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
{117DA02F-5274-4565-ACC6-DA9B6E568B09} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
{DEFF4009-1FAB-4392-80B6-707E2DC5C00B} = {664D406C-2F83-48F0-BFC3-408D5CB53C65}
{DC37FD87-552C-4613-9F16-1537CA522898} = {664D406C-2F83-48F0-BFC3-408D5CB53C65}
{4E4EE20C-F06A-4A1B-851F-C5577796941C} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
{EA6127A5-94C9-4C31-AD11-E6811B92B520} = {664D406C-2F83-48F0-BFC3-408D5CB53C65}
{01FA6786-921D-4CE8-8C50-4FDA66C9477D} = {664D406C-2F83-48F0-BFC3-408D5CB53C65}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3CB609D9-5D54-4C11-A371-DAAC8B74E430}
EndGlobalSection
EndGlobal

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# Hutopy
## Pattern used
- Clean Architecture
- Guards ( Fail fast ) : https://github.com/ardalis/GuardClauses
## Tools
- Install Docker : https://www.docker.com/get-started/
- Install sql server management ( or preffered tool ) : https://learn.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-ver16#download-ssms
## Database setup in docker for local dev
```
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=<YourPassword>" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest
```
Or with a mounted volume to persist data on the computer instead ( persist data even if the container is deleted )
```
docker run -e 'ACCEPT_EULA=Y' -e 'MSSQL_SA_PASSWORD=<YourPassword>' -p 1433:1433 -v C:\dev\DockerVolumes\SqlServer-Utopy-1\data:/var/opt/mssql/data -v C:\dev\DockerVolumes\SqlServer-Utopy-1\log:/var/opt/mssql/log -v C:\dev\DockerVolumes\SqlServer-Utopy-1\secrets:/var/opt/mssql/secrets -d mcr.microsoft.com/mssql/server:2022-latest
```
Set your password in an env var for the connection string. Windows : $Env:DB_PASSWORD = "YourPassword"
## Build
Run `dotnet build -tl` to build the solution.
## Run
To run the web application:
```bash
cd .\src\Web\
dotnet watch run
```
Navigate to https://localhost:5001. The application will automatically reload if you change any of the source files.
## Code Styles & Formatting
The template includes [EditorConfig](https://editorconfig.org/) support to help maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. The **.editorconfig** file defines the coding styles applicable to this solution.
## Code Scaffolding
Scaffold new commands and queries.
Start in the `.\src\Application\` folder.
Create a new command:
```
dotnet new ca-usecase --name CreateTodoList --feature-name TodoLists --usecase-type command --return-type int
```
Create a new query:
```
dotnet new ca-usecase -n GetTodos -fn TodoLists -ut query -rt TodosVm
```
If you encounter the error *"No templates or subcommands found matching: 'ca-usecase'."*, install the template and try again:
```bash
dotnet new install Clean.Architecture.Solution.Template::8.0.4
```
## Test
The solution contains unit, integration, and functional tests.
- Using Moq, Nunit, Respawn, FluentAssertions
To run the tests:
```bash
dotnet test
```

6
global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.100",
"rollForward": "latestFeature"
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Hutopy.Application</RootNamespace>
<AssemblyName>Hutopy.Application</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Ardalis.GuardClauses" />
<PackageReference Include="AutoMapper" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,79 @@
using System.Reflection;
using Hutopy.Application.Common.Exceptions;
using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Common.Security;
namespace Hutopy.Application.Common.Behaviours;
public class AuthorizationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
private readonly IUser _user;
private readonly IIdentityService _identityService;
public AuthorizationBehaviour(
IUser user,
IIdentityService identityService)
{
_user = user;
_identityService = identityService;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var authorizeAttributes = request.GetType().GetCustomAttributes<AuthorizeAttribute>();
if (authorizeAttributes.Any())
{
// Must be authenticated user
if (_user.Id == null)
{
throw new UnauthorizedAccessException();
}
// Role-based authorization
var authorizeAttributesWithRoles = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Roles));
if (authorizeAttributesWithRoles.Any())
{
var authorized = false;
foreach (var roles in authorizeAttributesWithRoles.Select(a => a.Roles.Split(',')))
{
foreach (var role in roles)
{
var isInRole = await _identityService.IsInRoleAsync(_user.Id, role.Trim());
if (isInRole)
{
authorized = true;
break;
}
}
}
// Must be a member of at least one role in roles
if (!authorized)
{
throw new ForbiddenAccessException();
}
}
// Policy-based authorization
var authorizeAttributesWithPolicies = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Policy));
if (authorizeAttributesWithPolicies.Any())
{
foreach (var policy in authorizeAttributesWithPolicies.Select(a => a.Policy))
{
var authorized = await _identityService.AuthorizeAsync(_user.Id, policy);
if (!authorized)
{
throw new ForbiddenAccessException();
}
}
}
}
// User is authorized / authorization not required
return await next();
}
}

View File

@@ -0,0 +1,34 @@
using Hutopy.Application.Common.Interfaces;
using MediatR.Pipeline;
using Microsoft.Extensions.Logging;
namespace Hutopy.Application.Common.Behaviours;
public class LoggingBehaviour<TRequest> : IRequestPreProcessor<TRequest> where TRequest : notnull
{
private readonly ILogger _logger;
private readonly IUser _user;
private readonly IIdentityService _identityService;
public LoggingBehaviour(ILogger<TRequest> logger, IUser user, IIdentityService identityService)
{
_logger = logger;
_user = user;
_identityService = identityService;
}
public async Task Process(TRequest request, CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
var userId = _user.Id ?? string.Empty;
string? userName = string.Empty;
if (!string.IsNullOrEmpty(userId))
{
userName = await _identityService.GetUserNameAsync(userId);
}
_logger.LogInformation("Hutopy Request: {Name} {@UserId} {@UserName} {@Request}",
requestName, userId, userName, request);
}
}

View File

@@ -0,0 +1,53 @@
using System.Diagnostics;
using Hutopy.Application.Common.Interfaces;
using Microsoft.Extensions.Logging;
namespace Hutopy.Application.Common.Behaviours;
public class PerformanceBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
private readonly Stopwatch _timer;
private readonly ILogger<TRequest> _logger;
private readonly IUser _user;
private readonly IIdentityService _identityService;
public PerformanceBehaviour(
ILogger<TRequest> logger,
IUser user,
IIdentityService identityService)
{
_timer = new Stopwatch();
_logger = logger;
_user = user;
_identityService = identityService;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
_timer.Start();
var response = await next();
_timer.Stop();
var elapsedMilliseconds = _timer.ElapsedMilliseconds;
if (elapsedMilliseconds > 500)
{
var requestName = typeof(TRequest).Name;
var userId = _user.Id ?? string.Empty;
var userName = string.Empty;
if (!string.IsNullOrEmpty(userId))
{
userName = await _identityService.GetUserNameAsync(userId);
}
_logger.LogWarning("Hutopy Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@UserName} {@Request}",
requestName, elapsedMilliseconds, userId, userName, request);
}
return response;
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.Logging;
namespace Hutopy.Application.Common.Behaviours;
public class UnhandledExceptionBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
private readonly ILogger<TRequest> _logger;
public UnhandledExceptionBehaviour(ILogger<TRequest> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
try
{
return await next();
}
catch (Exception ex)
{
var requestName = typeof(TRequest).Name;
_logger.LogError(ex, "Hutopy Request: Unhandled Exception for Request {Name} {@Request}", requestName, request);
throw;
}
}
}

View File

@@ -0,0 +1,35 @@
using ValidationException = Hutopy.Application.Common.Exceptions.ValidationException;
namespace Hutopy.Application.Common.Behaviours;
public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (_validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v =>
v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.Where(r => r.Errors.Any())
.SelectMany(r => r.Errors)
.ToList();
if (failures.Any())
throw new ValidationException(failures);
}
return await next();
}
}

View File

@@ -0,0 +1,6 @@
namespace Hutopy.Application.Common.Exceptions;
public class ForbiddenAccessException : Exception
{
public ForbiddenAccessException() : base() { }
}

View File

@@ -0,0 +1,22 @@
using FluentValidation.Results;
namespace Hutopy.Application.Common.Exceptions;
public class ValidationException : Exception
{
public ValidationException()
: base("One or more validation failures have occurred.")
{
Errors = new Dictionary<string, string[]>();
}
public ValidationException(IEnumerable<ValidationFailure> failures)
: this()
{
Errors = failures
.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
.ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray());
}
public IDictionary<string, string[]> Errors { get; }
}

View File

@@ -0,0 +1,12 @@
using Hutopy.Domain.Entities;
namespace Hutopy.Application.Common.Interfaces;
public interface IApplicationDbContext
{
DbSet<TodoList> TodoLists { get; }
DbSet<TodoItem> TodoItems { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,16 @@
using Hutopy.Application.Common.Models;
namespace Hutopy.Application.Common.Interfaces;
public interface IIdentityService
{
Task<string?> GetUserNameAsync(string userId);
Task<bool> IsInRoleAsync(string userId, string role);
Task<bool> AuthorizeAsync(string userId, string policyName);
Task<(Result Result, string UserId)> CreateUserAsync(string userName, string password);
Task<Result> DeleteUserAsync(string userId);
}

View File

@@ -0,0 +1,6 @@
namespace Hutopy.Application.Common.Interfaces;
public interface IUser
{
string? Id { get; }
}

View File

@@ -0,0 +1,12 @@
using Hutopy.Application.Common.Models;
namespace Hutopy.Application.Common.Mappings;
public static class MappingExtensions
{
public static Task<PaginatedList<TDestination>> PaginatedListAsync<TDestination>(this IQueryable<TDestination> queryable, int pageNumber, int pageSize) where TDestination : class
=> PaginatedList<TDestination>.CreateAsync(queryable.AsNoTracking(), pageNumber, pageSize);
public static Task<List<TDestination>> ProjectToListAsync<TDestination>(this IQueryable queryable, IConfigurationProvider configuration) where TDestination : class
=> queryable.ProjectTo<TDestination>(configuration).AsNoTracking().ToListAsync();
}

View File

@@ -0,0 +1,19 @@
using Hutopy.Domain.Entities;
namespace Hutopy.Application.Common.Models;
public class LookupDto
{
public int Id { get; init; }
public string? Title { get; init; }
private class Mapping : Profile
{
public Mapping()
{
CreateMap<TodoList, LookupDto>();
CreateMap<TodoItem, LookupDto>();
}
}
}

View File

@@ -0,0 +1,29 @@
namespace Hutopy.Application.Common.Models;
public class PaginatedList<T>
{
public IReadOnlyCollection<T> Items { get; }
public int PageNumber { get; }
public int TotalPages { get; }
public int TotalCount { get; }
public PaginatedList(IReadOnlyCollection<T> items, int count, int pageNumber, int pageSize)
{
PageNumber = pageNumber;
TotalPages = (int)Math.Ceiling(count / (double)pageSize);
TotalCount = count;
Items = items;
}
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber < TotalPages;
public static async Task<PaginatedList<T>> CreateAsync(IQueryable<T> source, int pageNumber, int pageSize)
{
var count = await source.CountAsync();
var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync();
return new PaginatedList<T>(items, count, pageNumber, pageSize);
}
}

View File

@@ -0,0 +1,24 @@
namespace Hutopy.Application.Common.Models;
public class Result
{
internal Result(bool succeeded, IEnumerable<string> errors)
{
Succeeded = succeeded;
Errors = errors.ToArray();
}
public bool Succeeded { get; init; }
public string[] Errors { get; init; }
public static Result Success()
{
return new Result(true, Array.Empty<string>());
}
public static Result Failure(IEnumerable<string> errors)
{
return new Result(false, errors);
}
}

View File

@@ -0,0 +1,23 @@
namespace Hutopy.Application.Common.Security;
/// <summary>
/// Specifies the class this attribute is applied to requires authorization.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class AuthorizeAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="AuthorizeAttribute"/> class.
/// </summary>
public AuthorizeAttribute() { }
/// <summary>
/// Gets or sets a comma delimited list of roles that are allowed to access the resource.
/// </summary>
public string Roles { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the policy name that determines access to the resource.
/// </summary>
public string Policy { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,25 @@
using System.Reflection;
using Hutopy.Application.Common.Behaviours;
namespace Microsoft.Extensions.DependencyInjection;
public static class DependencyInjection
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
services.AddAutoMapper(Assembly.GetExecutingAssembly());
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
//cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(UnhandledExceptionBehaviour<,>));
//cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehaviour<,>));
//cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
//cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>));
});
return services;
}
}

View File

@@ -0,0 +1,6 @@
global using Ardalis.GuardClauses;
global using AutoMapper;
global using AutoMapper.QueryableExtensions;
global using Microsoft.EntityFrameworkCore;
global using FluentValidation;
global using MediatR;

View File

@@ -0,0 +1,40 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Domain.Entities;
using Hutopy.Domain.Events;
namespace Hutopy.Application.TodoItems.Commands.CreateTodoItem;
public record CreateTodoItemCommand : IRequest<int>
{
public int ListId { get; init; }
public string? Title { get; init; }
}
public class CreateTodoItemCommandHandler : IRequestHandler<CreateTodoItemCommand, int>
{
private readonly IApplicationDbContext _context;
public CreateTodoItemCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<int> Handle(CreateTodoItemCommand request, CancellationToken cancellationToken)
{
var entity = new TodoItem
{
ListId = request.ListId,
Title = request.Title,
Done = false
};
entity.AddDomainEvent(new TodoItemCreatedEvent(entity));
_context.TodoItems.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}

View File

@@ -0,0 +1,11 @@
namespace Hutopy.Application.TodoItems.Commands.CreateTodoItem;
public class CreateTodoItemCommandValidator : AbstractValidator<CreateTodoItemCommand>
{
public CreateTodoItemCommandValidator()
{
RuleFor(v => v.Title)
.MaximumLength(200)
.NotEmpty();
}
}

View File

@@ -0,0 +1,35 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Domain.Events;
using Hutopy.Application.Common.Security;
using Hutopy.Domain.Constants;
namespace Hutopy.Application.TodoItems.Commands.DeleteTodoItem;
[Authorize(Roles = Roles.Administrator)]
[Authorize(Policy = Policies.CanDelete)]
public record DeleteTodoItemCommand(int Id) : IRequest;
public class DeleteTodoItemCommandHandler : IRequestHandler<DeleteTodoItemCommand>
{
private readonly IApplicationDbContext _context;
public DeleteTodoItemCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task Handle(DeleteTodoItemCommand request, CancellationToken cancellationToken)
{
var entity = await _context.TodoItems
.FindAsync(new object[] { request.Id }, cancellationToken);
Guard.Against.NotFound(request.Id, entity);
_context.TodoItems.Remove(entity);
entity.AddDomainEvent(new TodoItemDeletedEvent(entity));
await _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,35 @@
using Hutopy.Application.Common.Interfaces;
namespace Hutopy.Application.TodoItems.Commands.UpdateTodoItem;
public record UpdateTodoItemCommand : IRequest
{
public int Id { get; init; }
public string? Title { get; init; }
public bool Done { get; init; }
}
public class UpdateTodoItemCommandHandler : IRequestHandler<UpdateTodoItemCommand>
{
private readonly IApplicationDbContext _context;
public UpdateTodoItemCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task Handle(UpdateTodoItemCommand request, CancellationToken cancellationToken)
{
var entity = await _context.TodoItems
.FindAsync(new object[] { request.Id }, cancellationToken);
Guard.Against.NotFound(request.Id, entity);
entity.Title = request.Title;
entity.Done = request.Done;
await _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,11 @@
namespace Hutopy.Application.TodoItems.Commands.UpdateTodoItem;
public class UpdateTodoItemCommandValidator : AbstractValidator<UpdateTodoItemCommand>
{
public UpdateTodoItemCommandValidator()
{
RuleFor(v => v.Title)
.MaximumLength(200)
.NotEmpty();
}
}

View File

@@ -0,0 +1,39 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Domain.Enums;
namespace Hutopy.Application.TodoItems.Commands.UpdateTodoItemDetail;
public record UpdateTodoItemDetailCommand : IRequest
{
public int Id { get; init; }
public int ListId { get; init; }
public PriorityLevel Priority { get; init; }
public string? Note { get; init; }
}
public class UpdateTodoItemDetailCommandHandler : IRequestHandler<UpdateTodoItemDetailCommand>
{
private readonly IApplicationDbContext _context;
public UpdateTodoItemDetailCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task Handle(UpdateTodoItemDetailCommand request, CancellationToken cancellationToken)
{
var entity = await _context.TodoItems
.FindAsync(new object[] { request.Id }, cancellationToken);
Guard.Against.NotFound(request.Id, entity);
entity.ListId = request.ListId;
entity.Priority = request.Priority;
entity.Note = request.Note;
await _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,21 @@
using Hutopy.Domain.Events;
using Microsoft.Extensions.Logging;
namespace Hutopy.Application.TodoItems.EventHandlers;
public class TodoItemCompletedEventHandler : INotificationHandler<TodoItemCompletedEvent>
{
private readonly ILogger<TodoItemCompletedEventHandler> _logger;
public TodoItemCompletedEventHandler(ILogger<TodoItemCompletedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(TodoItemCompletedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Hutopy Domain Event: {DomainEvent}", notification.GetType().Name);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,21 @@
using Hutopy.Domain.Events;
using Microsoft.Extensions.Logging;
namespace Hutopy.Application.TodoItems.EventHandlers;
public class TodoItemCreatedEventHandler : INotificationHandler<TodoItemCreatedEvent>
{
private readonly ILogger<TodoItemCreatedEventHandler> _logger;
public TodoItemCreatedEventHandler(ILogger<TodoItemCreatedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(TodoItemCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Hutopy Domain Event: {DomainEvent}", notification.GetType().Name);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,34 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Common.Mappings;
using Hutopy.Application.Common.Models;
namespace Hutopy.Application.TodoItems.Queries.GetTodoItemsWithPagination;
public record GetTodoItemsWithPaginationQuery : IRequest<PaginatedList<TodoItemBriefDto>>
{
public int ListId { get; init; }
public int PageNumber { get; init; } = 1;
public int PageSize { get; init; } = 10;
}
public class GetTodoItemsWithPaginationQueryHandler : IRequestHandler<GetTodoItemsWithPaginationQuery, PaginatedList<TodoItemBriefDto>>
{
private readonly IApplicationDbContext _context;
private readonly IMapper _mapper;
public GetTodoItemsWithPaginationQueryHandler(IApplicationDbContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public async Task<PaginatedList<TodoItemBriefDto>> Handle(GetTodoItemsWithPaginationQuery request, CancellationToken cancellationToken)
{
Console.WriteLine(request);
return await _context.TodoItems
.Where(x => x.ListId == request.ListId)
.OrderBy(x => x.Title)
.ProjectTo<TodoItemBriefDto>(_mapper.ConfigurationProvider)
.PaginatedListAsync(request.PageNumber, request.PageSize);
}
}

View File

@@ -0,0 +1,16 @@
namespace Hutopy.Application.TodoItems.Queries.GetTodoItemsWithPagination;
public class GetTodoItemsWithPaginationQueryValidator : AbstractValidator<GetTodoItemsWithPaginationQuery>
{
public GetTodoItemsWithPaginationQueryValidator()
{
RuleFor(x => x.ListId)
.NotEmpty().WithMessage("ListId is required.");
RuleFor(x => x.PageNumber)
.GreaterThanOrEqualTo(1).WithMessage("PageNumber at least greater than or equal to 1.");
RuleFor(x => x.PageSize)
.GreaterThanOrEqualTo(1).WithMessage("PageSize at least greater than or equal to 1.");
}
}

View File

@@ -0,0 +1,22 @@
using Hutopy.Domain.Entities;
namespace Hutopy.Application.TodoItems.Queries.GetTodoItemsWithPagination;
public class TodoItemBriefDto
{
public int Id { get; init; }
public int ListId { get; init; }
public string? Title { get; init; }
public bool Done { get; init; }
private class Mapping : Profile
{
public Mapping()
{
CreateMap<TodoItem, TodoItemBriefDto>();
}
}
}

View File

@@ -0,0 +1,32 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Domain.Entities;
namespace Hutopy.Application.TodoLists.Commands.CreateTodoList;
public record CreateTodoListCommand : IRequest<int>
{
public string? Title { get; init; }
}
public class CreateTodoListCommandHandler : IRequestHandler<CreateTodoListCommand, int>
{
private readonly IApplicationDbContext _context;
public CreateTodoListCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<int> Handle(CreateTodoListCommand request, CancellationToken cancellationToken)
{
var entity = new TodoList();
entity.Title = request.Title;
_context.TodoLists.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}

View File

@@ -0,0 +1,26 @@
using Hutopy.Application.Common.Interfaces;
namespace Hutopy.Application.TodoLists.Commands.CreateTodoList;
public class CreateTodoListCommandValidator : AbstractValidator<CreateTodoListCommand>
{
private readonly IApplicationDbContext _context;
public CreateTodoListCommandValidator(IApplicationDbContext context)
{
_context = context;
RuleFor(v => v.Title)
.NotEmpty()
.MaximumLength(200)
.MustAsync(BeUniqueTitle)
.WithMessage("'{PropertyName}' must be unique.")
.WithErrorCode("Unique");
}
public async Task<bool> BeUniqueTitle(string title, CancellationToken cancellationToken)
{
return await _context.TodoLists
.AllAsync(l => l.Title != title, cancellationToken);
}
}

View File

@@ -0,0 +1,28 @@
using Hutopy.Application.Common.Interfaces;
namespace Hutopy.Application.TodoLists.Commands.DeleteTodoList;
public record DeleteTodoListCommand(int Id) : IRequest;
public class DeleteTodoListCommandHandler : IRequestHandler<DeleteTodoListCommand>
{
private readonly IApplicationDbContext _context;
public DeleteTodoListCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task Handle(DeleteTodoListCommand request, CancellationToken cancellationToken)
{
var entity = await _context.TodoLists
.Where(l => l.Id == request.Id)
.SingleOrDefaultAsync(cancellationToken);
Guard.Against.NotFound(request.Id, entity);
_context.TodoLists.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,26 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Common.Security;
using Hutopy.Domain.Constants;
namespace Hutopy.Application.TodoLists.Commands.PurgeTodoLists;
[Authorize(Roles = Roles.Administrator)]
[Authorize(Policy = Policies.CanPurge)]
public record PurgeTodoListsCommand : IRequest;
public class PurgeTodoListsCommandHandler : IRequestHandler<PurgeTodoListsCommand>
{
private readonly IApplicationDbContext _context;
public PurgeTodoListsCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task Handle(PurgeTodoListsCommand request, CancellationToken cancellationToken)
{
_context.TodoLists.RemoveRange(_context.TodoLists);
await _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,33 @@
using Hutopy.Application.Common.Interfaces;
namespace Hutopy.Application.TodoLists.Commands.UpdateTodoList;
public record UpdateTodoListCommand : IRequest
{
public int Id { get; init; }
public string? Title { get; init; }
}
public class UpdateTodoListCommandHandler : IRequestHandler<UpdateTodoListCommand>
{
private readonly IApplicationDbContext _context;
public UpdateTodoListCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task Handle(UpdateTodoListCommand request, CancellationToken cancellationToken)
{
var entity = await _context.TodoLists
.FindAsync(new object[] { request.Id }, cancellationToken);
Guard.Against.NotFound(request.Id, entity);
entity.Title = request.Title;
await _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,27 @@
using Hutopy.Application.Common.Interfaces;
namespace Hutopy.Application.TodoLists.Commands.UpdateTodoList;
public class UpdateTodoListCommandValidator : AbstractValidator<UpdateTodoListCommand>
{
private readonly IApplicationDbContext _context;
public UpdateTodoListCommandValidator(IApplicationDbContext context)
{
_context = context;
RuleFor(v => v.Title)
.NotEmpty()
.MaximumLength(200)
.MustAsync(BeUniqueTitle)
.WithMessage("'{PropertyName}' must be unique.")
.WithErrorCode("Unique");
}
public async Task<bool> BeUniqueTitle(UpdateTodoListCommand model, string title, CancellationToken cancellationToken)
{
return await _context.TodoLists
.Where(l => l.Id != model.Id)
.AllAsync(l => l.Title != title, cancellationToken);
}
}

View File

@@ -0,0 +1,38 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Common.Models;
using Hutopy.Application.Common.Security;
using Hutopy.Domain.Enums;
namespace Hutopy.Application.TodoLists.Queries.GetTodos;
[Authorize]
public record GetTodosQuery : IRequest<TodosVm>;
public class GetTodosQueryHandler : IRequestHandler<GetTodosQuery, TodosVm>
{
private readonly IApplicationDbContext _context;
private readonly IMapper _mapper;
public GetTodosQueryHandler(IApplicationDbContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public async Task<TodosVm> Handle(GetTodosQuery request, CancellationToken cancellationToken)
{
return new TodosVm
{
PriorityLevels = Enum.GetValues(typeof(PriorityLevel))
.Cast<PriorityLevel>()
.Select(p => new LookupDto { Id = (int)p, Title = p.ToString() })
.ToList(),
Lists = await _context.TodoLists
.AsNoTracking()
.ProjectTo<TodoListDto>(_mapper.ConfigurationProvider)
.OrderBy(t => t.Title)
.ToListAsync(cancellationToken)
};
}
}

View File

@@ -0,0 +1,27 @@
using Hutopy.Domain.Entities;
namespace Hutopy.Application.TodoLists.Queries.GetTodos;
public class TodoItemDto
{
public int Id { get; init; }
public int ListId { get; init; }
public string? Title { get; init; }
public bool Done { get; init; }
public int Priority { get; init; }
public string? Note { get; init; }
private class Mapping : Profile
{
public Mapping()
{
CreateMap<TodoItem, TodoItemDto>().ForMember(d => d.Priority,
opt => opt.MapFrom(s => (int)s.Priority));
}
}
}

View File

@@ -0,0 +1,27 @@
using Hutopy.Domain.Entities;
namespace Hutopy.Application.TodoLists.Queries.GetTodos;
public class TodoListDto
{
public TodoListDto()
{
Items = Array.Empty<TodoItemDto>();
}
public int Id { get; init; }
public string? Title { get; init; }
public string? Colour { get; init; }
public IReadOnlyCollection<TodoItemDto> Items { get; init; }
private class Mapping : Profile
{
public Mapping()
{
CreateMap<TodoList, TodoListDto>();
}
}
}

View File

@@ -0,0 +1,10 @@
using Hutopy.Application.Common.Models;
namespace Hutopy.Application.TodoLists.Queries.GetTodos;
public class TodosVm
{
public IReadOnlyCollection<LookupDto> PriorityLevels { get; init; } = Array.Empty<LookupDto>();
public IReadOnlyCollection<TodoListDto> Lists { get; init; } = Array.Empty<TodoListDto>();
}

View File

@@ -0,0 +1,25 @@
namespace Hutopy.Application.WeatherForecasts.Queries.GetWeatherForecasts;
public record GetWeatherForecastsQuery : IRequest<IEnumerable<WeatherForecast>>;
public class GetWeatherForecastsQueryHandler : IRequestHandler<GetWeatherForecastsQuery, IEnumerable<WeatherForecast>>
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
public async Task<IEnumerable<WeatherForecast>> Handle(GetWeatherForecastsQuery request, CancellationToken cancellationToken)
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
{
var rng = new Random();
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
});
}
}

View File

@@ -0,0 +1,12 @@
namespace Hutopy.Application.WeatherForecasts.Queries.GetWeatherForecasts;
public class WeatherForecast
{
public DateTime Date { get; init; }
public int TemperatureC { get; init; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace Hutopy.Domain.Common;
public abstract class BaseAuditableEntity : BaseEntity
{
public DateTimeOffset Created { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset LastModified { get; set; }
public string? LastModifiedBy { get; set; }
}

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Hutopy.Domain.Common;
public abstract class BaseEntity
{
// This can easily be modified to be BaseEntity<T> and public T Id to support different key types.
// Using non-generic integer types for simplicity
public int Id { get; set; }
private readonly List<BaseEvent> _domainEvents = new();
[NotMapped]
public IReadOnlyCollection<BaseEvent> DomainEvents => _domainEvents.AsReadOnly();
public void AddDomainEvent(BaseEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void RemoveDomainEvent(BaseEvent domainEvent)
{
_domainEvents.Remove(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}

View File

@@ -0,0 +1,7 @@
using MediatR;
namespace Hutopy.Domain.Common;
public abstract class BaseEvent : INotification
{
}

View File

@@ -0,0 +1,45 @@
namespace Hutopy.Domain.Common;
// Learn more: https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/microservice-ddd-cqrs-patterns/implement-value-objects
public abstract class ValueObject
{
protected static bool EqualOperator(ValueObject left, ValueObject right)
{
if (left is null ^ right is null)
{
return false;
}
return left?.Equals(right!) != false;
}
protected static bool NotEqualOperator(ValueObject left, ValueObject right)
{
return !(EqualOperator(left, right));
}
protected abstract IEnumerable<object> GetEqualityComponents();
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != GetType())
{
return false;
}
var other = (ValueObject)obj;
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
var hash = new HashCode();
foreach (var component in GetEqualityComponents())
{
hash.Add(component);
}
return hash.ToHashCode();
}
}

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Domain.Constants;
public abstract class Policies
{
public const string CanPurge = nameof(CanPurge);
public const string CanDelete = nameof(CanDelete);
}

View File

@@ -0,0 +1,6 @@
namespace Hutopy.Domain.Constants;
public abstract class Roles
{
public const string Administrator = nameof(Administrator);
}

12
src/Domain/Domain.csproj Normal file
View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Hutopy.Domain</RootNamespace>
<AssemblyName>Hutopy.Domain</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,31 @@
namespace Hutopy.Domain.Entities;
public class TodoItem : BaseAuditableEntity
{
public int ListId { get; set; }
public string? Title { get; set; }
public string? Note { get; set; }
public PriorityLevel Priority { get; set; }
public DateTime? Reminder { get; set; }
private bool _done;
public bool Done
{
get => _done;
set
{
if (value && !_done)
{
AddDomainEvent(new TodoItemCompletedEvent(this));
}
_done = value;
}
}
public TodoList List { get; set; } = null!;
}

View File

@@ -0,0 +1,10 @@
namespace Hutopy.Domain.Entities;
public class TodoList : BaseAuditableEntity
{
public string? Title { get; set; }
public Colour Colour { get; set; } = Colour.White;
public IList<TodoItem> Items { get; private set; } = new List<TodoItem>();
}

View File

@@ -0,0 +1,9 @@
namespace Hutopy.Domain.Enums;
public enum PriorityLevel
{
None = 0,
Low = 1,
Medium = 2,
High = 3
}

View File

@@ -0,0 +1,11 @@
namespace Hutopy.Domain.Events;
public class TodoItemCompletedEvent : BaseEvent
{
public TodoItemCompletedEvent(TodoItem item)
{
Item = item;
}
public TodoItem Item { get; }
}

View File

@@ -0,0 +1,11 @@
namespace Hutopy.Domain.Events;
public class TodoItemCreatedEvent : BaseEvent
{
public TodoItemCreatedEvent(TodoItem item)
{
Item = item;
}
public TodoItem Item { get; }
}

View File

@@ -0,0 +1,11 @@
namespace Hutopy.Domain.Events;
public class TodoItemDeletedEvent : BaseEvent
{
public TodoItemDeletedEvent(TodoItem item)
{
Item = item;
}
public TodoItem Item { get; }
}

View File

@@ -0,0 +1,9 @@
namespace Hutopy.Domain.Exceptions;
public class UnsupportedColourException : Exception
{
public UnsupportedColourException(string code)
: base($"Colour \"{code}\" is unsupported.")
{
}
}

View File

@@ -0,0 +1,6 @@
global using Hutopy.Domain.Common;
global using Hutopy.Domain.Entities;
global using Hutopy.Domain.Enums;
global using Hutopy.Domain.Events;
global using Hutopy.Domain.Exceptions;
global using Hutopy.Domain.ValueObjects;

View File

@@ -0,0 +1,69 @@
namespace Hutopy.Domain.ValueObjects;
public class Colour(string code) : ValueObject
{
public static Colour From(string code)
{
var colour = new Colour(code);
if (!SupportedColours.Contains(colour))
{
throw new UnsupportedColourException(code);
}
return colour;
}
public static Colour White => new("#FFFFFF");
public static Colour Red => new("#FF5733");
public static Colour Orange => new("#FFC300");
public static Colour Yellow => new("#FFFF66");
public static Colour Green => new("#CCFF99");
public static Colour Blue => new("#6666FF");
public static Colour Purple => new("#9966CC");
public static Colour Grey => new("#999999");
public string Code { get; private set; } = string.IsNullOrWhiteSpace(code)?"#000000":code;
public static implicit operator string(Colour colour)
{
return colour.ToString();
}
public static explicit operator Colour(string code)
{
return From(code);
}
public override string ToString()
{
return Code;
}
protected static IEnumerable<Colour> SupportedColours
{
get
{
yield return White;
yield return Red;
yield return Orange;
yield return Yellow;
yield return Green;
yield return Blue;
yield return Purple;
yield return Grey;
}
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Code;
}
}

View File

@@ -0,0 +1,23 @@
using System.Reflection;
using Hutopy.Application.Common.Interfaces;
using Hutopy.Domain.Entities;
using Hutopy.Infrastructure.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace Hutopy.Infrastructure.Data;
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IApplicationDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
public DbSet<TodoList> TodoLists => Set<TodoList>();
public DbSet<TodoItem> TodoItems => Set<TodoItem>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
}

View File

@@ -0,0 +1,109 @@
using System.Runtime.InteropServices;
using Hutopy.Domain.Constants;
using Hutopy.Domain.Entities;
using Hutopy.Infrastructure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Hutopy.Infrastructure.Data;
public static class InitialiserExtensions
{
public static async Task InitialiseDatabaseAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var initialiser = scope.ServiceProvider.GetRequiredService<ApplicationDbContextInitialiser>();
await initialiser.InitialiseAsync();
await initialiser.SeedAsync();
}
}
public class ApplicationDbContextInitialiser
{
private readonly ILogger<ApplicationDbContextInitialiser> _logger;
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
public ApplicationDbContextInitialiser(ILogger<ApplicationDbContextInitialiser> logger, ApplicationDbContext context, UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager)
{
_logger = logger;
_context = context;
_userManager = userManager;
_roleManager = roleManager;
}
public async Task InitialiseAsync()
{
try
{
await _context.Database.MigrateAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while initialising the database.");
throw;
}
}
public async Task SeedAsync()
{
try
{
await TrySeedAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while seeding the database.");
throw;
}
}
public async Task TrySeedAsync()
{
// Default roles
var administratorRole = new IdentityRole(Roles.Administrator);
if (_roleManager.Roles.All(r => r.Name != administratorRole.Name))
{
await _roleManager.CreateAsync(administratorRole);
}
// Default users
var administrator = new ApplicationUser { UserName = "administrator@localhost", Email = "administrator@localhost" };
if (_userManager.Users.All(u => u.UserName != administrator.UserName))
{
await _userManager.CreateAsync(administrator, "Administrator1!");
if (!string.IsNullOrWhiteSpace(administratorRole.Name))
{
await _userManager.AddToRolesAsync(administrator, new [] { administratorRole.Name });
}
}
// Default data
// Seed, if necessary
if (!_context.TodoLists.Any())
{
_context.TodoLists.Add(new TodoList
{
Title = "Todo List",
Items =
{
new TodoItem { Title = "Make a todo list 📃" },
new TodoItem { Title = "Check off the first item ✅" },
new TodoItem { Title = "Realise you've already done two things on the list! 🤯"},
new TodoItem { Title = "Reward yourself with a nice, long nap 🏆" },
}
});
await _context.SaveChangesAsync();
}
}
}

View File

@@ -0,0 +1,15 @@
using Hutopy.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Hutopy.Infrastructure.Data.Configurations;
public class TodoItemConfiguration : IEntityTypeConfiguration<TodoItem>
{
public void Configure(EntityTypeBuilder<TodoItem> builder)
{
builder.Property(t => t.Title)
.HasMaxLength(200)
.IsRequired();
}
}

View File

@@ -0,0 +1,18 @@
using Hutopy.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Hutopy.Infrastructure.Data.Configurations;
public class TodoListConfiguration : IEntityTypeConfiguration<TodoList>
{
public void Configure(EntityTypeBuilder<TodoList> builder)
{
builder.Property(t => t.Title)
.HasMaxLength(200)
.IsRequired();
builder
.OwnsOne(b => b.Colour);
}
}

View File

@@ -0,0 +1,64 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Domain.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Hutopy.Infrastructure.Data.Interceptors;
public class AuditableEntityInterceptor : SaveChangesInterceptor
{
private readonly IUser _user;
private readonly TimeProvider _dateTime;
public AuditableEntityInterceptor(
IUser user,
TimeProvider dateTime)
{
_user = user;
_dateTime = dateTime;
}
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
UpdateEntities(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
UpdateEntities(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
public void UpdateEntities(DbContext? context)
{
if (context == null) return;
foreach (var entry in context.ChangeTracker.Entries<BaseAuditableEntity>())
{
if (entry.State is EntityState.Added or EntityState.Modified || entry.HasChangedOwnedEntities())
{
var utcNow = _dateTime.GetUtcNow();
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedBy = _user.Id;
entry.Entity.Created = utcNow;
}
entry.Entity.LastModifiedBy = _user.Id;
entry.Entity.LastModified = utcNow;
}
}
}
}
public static class Extensions
{
public static bool HasChangedOwnedEntities(this EntityEntry entry) =>
entry.References.Any(r =>
r.TargetEntry != null &&
r.TargetEntry.Metadata.IsOwned() &&
(r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified));
}

View File

@@ -0,0 +1,50 @@
using Hutopy.Domain.Common;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Hutopy.Infrastructure.Data.Interceptors;
public class DispatchDomainEventsInterceptor : SaveChangesInterceptor
{
private readonly IMediator _mediator;
public DispatchDomainEventsInterceptor(IMediator mediator)
{
_mediator = mediator;
}
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
DispatchDomainEvents(eventData.Context).GetAwaiter().GetResult();
return base.SavingChanges(eventData, result);
}
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
await DispatchDomainEvents(eventData.Context);
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
public async Task DispatchDomainEvents(DbContext? context)
{
if (context == null) return;
var entities = context.ChangeTracker
.Entries<BaseEntity>()
.Where(e => e.Entity.DomainEvents.Any())
.Select(e => e.Entity);
var domainEvents = entities
.SelectMany(e => e.DomainEvents)
.ToList();
entities.ToList().ForEach(e => e.ClearDomainEvents());
foreach (var domainEvent in domainEvents)
await _mediator.Publish(domainEvent);
}
}

View File

@@ -0,0 +1,399 @@
// <auto-generated />
using System;
using Hutopy.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hutopy.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("00000000000000_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.0-preview.6.23329.4")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Domain.Entities.TodoItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("Created")
.HasColumnType("datetimeoffset");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Done")
.HasColumnType("bit");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("datetimeoffset");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("ListId")
.HasColumnType("int");
b.Property<string>("Note")
.HasColumnType("nvarchar(max)");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<DateTime?>("Reminder")
.HasColumnType("datetime2");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("ListId");
b.ToTable("TodoItems");
});
modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("Created")
.HasColumnType("datetimeoffset");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("datetimeoffset");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.ToTable("TodoLists");
});
modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ProviderKey")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Name")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Hutopy.Domain.Entities.TodoItem", b =>
{
b.HasOne("Hutopy.Domain.Entities.TodoList", "List")
.WithMany("Items")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b =>
{
b.OwnsOne("Hutopy.Domain.ValueObjects.Colour", "Colour", b1 =>
{
b1.Property<int>("TodoListId")
.HasColumnType("int");
b1.Property<string>("Code")
.IsRequired()
.HasColumnType("nvarchar(max)");
b1.HasKey("TodoListId");
b1.ToTable("TodoLists");
b1.WithOwner()
.HasForeignKey("TodoListId");
});
b.Navigation("Colour")
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,281 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
UserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "bit", nullable: false),
PasswordHash = table.Column<string>(type: "nvarchar(max)", nullable: true),
SecurityStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "bit", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "bit", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LockoutEnabled = table.Column<bool>(type: "bit", nullable: false),
AccessFailedCount = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TodoLists",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Colour_Code = table.Column<string>(type: "nvarchar(max)", nullable: false),
Created = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TodoLists", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
ProviderKey = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
ProviderDisplayName = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
LoginProvider = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TodoItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ListId = table.Column<int>(type: "int", nullable: false),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Note = table.Column<string>(type: "nvarchar(max)", nullable: true),
Priority = table.Column<int>(type: "int", nullable: false),
Reminder = table.Column<DateTime>(type: "datetime2", nullable: true),
Done = table.Column<bool>(type: "bit", nullable: false),
Created = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TodoItems", x => x.Id);
table.ForeignKey(
name: "FK_TodoItems_TodoLists_ListId",
column: x => x.ListId,
principalTable: "TodoLists",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true,
filter: "[NormalizedName] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true,
filter: "[NormalizedUserName] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_TodoItems_ListId",
table: "TodoItems",
column: "ListId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "TodoItems");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
migrationBuilder.DropTable(
name: "TodoLists");
}
}
}

View File

@@ -0,0 +1,396 @@
// <auto-generated />
using System;
using Hutopy.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hutopy.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.0-preview.6.23329.4")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Domain.Entities.TodoItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("Created")
.HasColumnType("datetimeoffset");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Done")
.HasColumnType("bit");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("datetimeoffset");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("ListId")
.HasColumnType("int");
b.Property<string>("Note")
.HasColumnType("nvarchar(max)");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<DateTime?>("Reminder")
.HasColumnType("datetime2");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("ListId");
b.ToTable("TodoItems");
});
modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("Created")
.HasColumnType("datetimeoffset");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("datetimeoffset");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.ToTable("TodoLists");
});
modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ProviderKey")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Name")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Hutopy.Domain.Entities.TodoItem", b =>
{
b.HasOne("Hutopy.Domain.Entities.TodoList", "List")
.WithMany("Items")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b =>
{
b.OwnsOne("Hutopy.Domain.ValueObjects.Colour", "Colour", b1 =>
{
b1.Property<int>("TodoListId")
.HasColumnType("int");
b1.Property<string>("Code")
.IsRequired()
.HasColumnType("nvarchar(max)");
b1.HasKey("TodoListId");
b1.ToTable("TodoLists");
b1.WithOwner()
.HasForeignKey("TodoListId");
});
b.Navigation("Colour")
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Hutopy.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Hutopy.Domain.Entities.TodoList", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,58 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Domain.Constants;
using Hutopy.Infrastructure.Data;
using Hutopy.Infrastructure.Data.Interceptors;
using Hutopy.Infrastructure.Identity;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
namespace Microsoft.Extensions.DependencyInjection;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration)
{
// Replace password in the connection string with env var.
var connectionString = configuration.GetConnectionString("DefaultConnection") ?? "";
var dbPassword = Environment.GetEnvironmentVariable("DB_PASSWORD");
connectionString = connectionString.Replace("{DB_PASSWORD}", dbPassword);
Guard.Against.Null(connectionString, message: "Connection string 'DefaultConnection' not found.");
services.AddScoped<ISaveChangesInterceptor, AuditableEntityInterceptor>();
services.AddScoped<ISaveChangesInterceptor, DispatchDomainEventsInterceptor>();
services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
options.AddInterceptors(sp.GetServices<ISaveChangesInterceptor>());
options.UseSqlServer(connectionString);
});
services.AddScoped<IApplicationDbContext>(provider => provider.GetRequiredService<ApplicationDbContext>());
services.AddScoped<ApplicationDbContextInitialiser>();
services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme);
services.AddAuthorizationBuilder();
services
.AddIdentityCore<ApplicationUser>()
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddApiEndpoints();
services.AddSingleton(TimeProvider.System);
services.AddTransient<IIdentityService, IdentityService>();
services.AddAuthorization(options =>
options.AddPolicy(Policies.CanPurge, policy => policy.RequireRole(Roles.Administrator)));
return services;
}
}

View File

@@ -0,0 +1 @@
global using Ardalis.GuardClauses;

View File

@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Infrastructure.Identity;
public class ApplicationUser : IdentityUser
{
}

View File

@@ -0,0 +1,14 @@
using Hutopy.Application.Common.Models;
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Infrastructure.Identity;
public static class IdentityResultExtensions
{
public static Result ToApplicationResult(this IdentityResult result)
{
return result.Succeeded
? Result.Success()
: Result.Failure(result.Errors.Select(e => e.Description));
}
}

View File

@@ -0,0 +1,80 @@
using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Common.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Infrastructure.Identity;
public class IdentityService : IIdentityService
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly IUserClaimsPrincipalFactory<ApplicationUser> _userClaimsPrincipalFactory;
private readonly IAuthorizationService _authorizationService;
public IdentityService(
UserManager<ApplicationUser> userManager,
IUserClaimsPrincipalFactory<ApplicationUser> userClaimsPrincipalFactory,
IAuthorizationService authorizationService)
{
_userManager = userManager;
_userClaimsPrincipalFactory = userClaimsPrincipalFactory;
_authorizationService = authorizationService;
}
public async Task<string?> GetUserNameAsync(string userId)
{
var user = await _userManager.FindByIdAsync(userId);
return user?.UserName;
}
public async Task<(Result Result, string UserId)> CreateUserAsync(string userName, string password)
{
var user = new ApplicationUser
{
UserName = userName,
Email = userName,
};
var result = await _userManager.CreateAsync(user, password);
return (result.ToApplicationResult(), user.Id);
}
public async Task<bool> IsInRoleAsync(string userId, string role)
{
var user = await _userManager.FindByIdAsync(userId);
return user != null && await _userManager.IsInRoleAsync(user, role);
}
public async Task<bool> AuthorizeAsync(string userId, string policyName)
{
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return false;
}
var principal = await _userClaimsPrincipalFactory.CreateAsync(user);
var result = await _authorizationService.AuthorizeAsync(principal, policyName);
return result.Succeeded;
}
public async Task<Result> DeleteUserAsync(string userId)
{
var user = await _userManager.FindByIdAsync(userId);
return user != null ? await DeleteUserAsync(user) : Result.Success();
}
public async Task<Result> DeleteUserAsync(ApplicationUser user)
{
var result = await _userManager.DeleteAsync(user);
return result.ToApplicationResult();
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Hutopy.Infrastructure</RootNamespace>
<AssemblyName>Hutopy.Infrastructure</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Application\Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,66 @@
using Azure.Identity;
using Hutopy.Application.Common.Interfaces;
using Hutopy.Infrastructure.Data;
using Hutopy.Web.Services;
using Microsoft.AspNetCore.Mvc;
using NSwag;
using NSwag.Generation.Processors.Security;
namespace Microsoft.Extensions.DependencyInjection;
public static class DependencyInjection
{
public static IServiceCollection AddWebServices(this IServiceCollection services)
{
services.AddDatabaseDeveloperPageExceptionFilter();
services.AddScoped<IUser, CurrentUser>();
services.AddHttpContextAccessor();
services.AddHealthChecks()
.AddDbContextCheck<ApplicationDbContext>();
services.AddExceptionHandler<CustomExceptionHandler>();
services.AddRazorPages();
// Customise default API behaviour
services.Configure<ApiBehaviorOptions>(options =>
options.SuppressModelStateInvalidFilter = true);
services.AddEndpointsApiExplorer();
services.AddOpenApiDocument((configure, sp) =>
{
configure.Title = "Hutopy API";
// Add JWT
configure.AddSecurity("JWT", Enumerable.Empty<string>(), new OpenApiSecurityScheme
{
Type = OpenApiSecuritySchemeType.ApiKey,
Name = "Authorization",
In = OpenApiSecurityApiKeyLocation.Header,
Description = "Type into the textbox: Bearer {your JWT token}."
});
configure.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("JWT"));
});
return services;
}
public static IServiceCollection AddKeyVaultIfConfigured(this IServiceCollection services, ConfigurationManager configuration)
{
var keyVaultUri = configuration["KeyVaultUri"];
if (!string.IsNullOrWhiteSpace(keyVaultUri))
{
configuration.AddAzureKeyVault(
new Uri(keyVaultUri),
new DefaultAzureCredential());
}
return services;
}
}

View File

@@ -0,0 +1,52 @@
using Hutopy.Application.Common.Models;
using Hutopy.Application.TodoItems.Commands.CreateTodoItem;
using Hutopy.Application.TodoItems.Commands.DeleteTodoItem;
using Hutopy.Application.TodoItems.Commands.UpdateTodoItem;
using Hutopy.Application.TodoItems.Commands.UpdateTodoItemDetail;
using Hutopy.Application.TodoItems.Queries.GetTodoItemsWithPagination;
namespace Hutopy.Web.Endpoints;
public class TodoItems : EndpointGroupBase
{
public override void Map(WebApplication app)
{
app.MapGroup(this)
.RequireAuthorization()
.MapGet(GetTodoItemsWithPagination)
.MapPost(CreateTodoItem)
.MapPut(UpdateTodoItem, "{id}")
.MapPut(UpdateTodoItemDetail, "UpdateDetail/{id}")
.MapDelete(DeleteTodoItem, "{id}");
}
public Task<PaginatedList<TodoItemBriefDto>> GetTodoItemsWithPagination(ISender sender, [AsParameters] GetTodoItemsWithPaginationQuery query)
{
return sender.Send(query);
}
public Task<int> CreateTodoItem(ISender sender, CreateTodoItemCommand command)
{
return sender.Send(command);
}
public async Task<IResult> UpdateTodoItem(ISender sender, int id, UpdateTodoItemCommand command)
{
if (id != command.Id) return Results.BadRequest();
await sender.Send(command);
return Results.NoContent();
}
public async Task<IResult> UpdateTodoItemDetail(ISender sender, int id, UpdateTodoItemDetailCommand command)
{
if (id != command.Id) return Results.BadRequest();
await sender.Send(command);
return Results.NoContent();
}
public async Task<IResult> DeleteTodoItem(ISender sender, int id)
{
await sender.Send(new DeleteTodoItemCommand(id));
return Results.NoContent();
}
}

View File

@@ -0,0 +1,42 @@
using Hutopy.Application.TodoLists.Commands.CreateTodoList;
using Hutopy.Application.TodoLists.Commands.DeleteTodoList;
using Hutopy.Application.TodoLists.Commands.UpdateTodoList;
using Hutopy.Application.TodoLists.Queries.GetTodos;
namespace Hutopy.Web.Endpoints;
public class TodoLists : EndpointGroupBase
{
public override void Map(WebApplication app)
{
app.MapGroup(this)
.RequireAuthorization()
.MapGet(GetTodoLists)
.MapPost(CreateTodoList)
.MapPut(UpdateTodoList, "{id}")
.MapDelete(DeleteTodoList, "{id}");
}
public Task<TodosVm> GetTodoLists(ISender sender)
{
return sender.Send(new GetTodosQuery());
}
public Task<int> CreateTodoList(ISender sender, CreateTodoListCommand command)
{
return sender.Send(command);
}
public async Task<IResult> UpdateTodoList(ISender sender, int id, UpdateTodoListCommand command)
{
if (id != command.Id) return Results.BadRequest();
await sender.Send(command);
return Results.NoContent();
}
public async Task<IResult> DeleteTodoList(ISender sender, int id)
{
await sender.Send(new DeleteTodoListCommand(id));
return Results.NoContent();
}
}

View File

@@ -0,0 +1,12 @@
using Hutopy.Infrastructure.Identity;
namespace Hutopy.Web.Endpoints;
public class Users : EndpointGroupBase
{
public override void Map(WebApplication app)
{
app.MapGroup(this)
.MapIdentityApi<ApplicationUser>();
}
}

View File

@@ -0,0 +1,18 @@
using Hutopy.Application.WeatherForecasts.Queries.GetWeatherForecasts;
namespace Hutopy.Web.Endpoints;
public class WeatherForecasts : EndpointGroupBase
{
public override void Map(WebApplication app)
{
app.MapGroup(this)
.RequireAuthorization()
.MapGet(GetWeatherForecasts);
}
public async Task<IEnumerable<WeatherForecast>> GetWeatherForecasts(ISender sender)
{
return await sender.Send(new GetWeatherForecastsQuery());
}
}

3
src/Web/GlobalUsings.cs Normal file
View File

@@ -0,0 +1,3 @@
global using Ardalis.GuardClauses;
global using Hutopy.Web.Infrastructure;
global using MediatR;

View File

@@ -0,0 +1,87 @@
using Hutopy.Application.Common.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace Hutopy.Web.Infrastructure;
public class CustomExceptionHandler : IExceptionHandler
{
private readonly Dictionary<Type, Func<HttpContext, Exception, Task>> _exceptionHandlers;
public CustomExceptionHandler()
{
// Register known exception types and handlers.
_exceptionHandlers = new()
{
{ typeof(ValidationException), HandleValidationException },
{ typeof(NotFoundException), HandleNotFoundException },
{ typeof(UnauthorizedAccessException), HandleUnauthorizedAccessException },
{ typeof(ForbiddenAccessException), HandleForbiddenAccessException },
};
}
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
var exceptionType = exception.GetType();
if (_exceptionHandlers.ContainsKey(exceptionType))
{
await _exceptionHandlers[exceptionType].Invoke(httpContext, exception);
return true;
}
return false;
}
private async Task HandleValidationException(HttpContext httpContext, Exception ex)
{
var exception = (ValidationException)ex;
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new ValidationProblemDetails(exception.Errors)
{
Status = StatusCodes.Status400BadRequest,
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
});
}
private async Task HandleNotFoundException(HttpContext httpContext, Exception ex)
{
var exception = (NotFoundException)ex;
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails()
{
Status = StatusCodes.Status404NotFound,
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4",
Title = "The specified resource was not found.",
Detail = exception.Message
});
}
private async Task HandleUnauthorizedAccessException(HttpContext httpContext, Exception ex)
{
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Type = "https://tools.ietf.org/html/rfc7235#section-3.1"
});
}
private async Task HandleForbiddenAccessException(HttpContext httpContext, Exception ex)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status403Forbidden,
Title = "Forbidden",
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.3"
});
}
}

View File

@@ -0,0 +1,6 @@
namespace Hutopy.Web.Infrastructure;
public abstract class EndpointGroupBase
{
public abstract void Map(WebApplication app);
}

View File

@@ -0,0 +1,46 @@
using System.Diagnostics.CodeAnalysis;
namespace Hutopy.Web.Infrastructure;
public static class IEndpointRouteBuilderExtensions
{
public static IEndpointRouteBuilder MapGet(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern = "")
{
Guard.Against.AnonymousMethod(handler);
builder.MapGet(pattern, handler)
.WithName(handler.Method.Name);
return builder;
}
public static IEndpointRouteBuilder MapPost(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern = "")
{
Guard.Against.AnonymousMethod(handler);
builder.MapPost(pattern, handler)
.WithName(handler.Method.Name);
return builder;
}
public static IEndpointRouteBuilder MapPut(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern)
{
Guard.Against.AnonymousMethod(handler);
builder.MapPut(pattern, handler)
.WithName(handler.Method.Name);
return builder;
}
public static IEndpointRouteBuilder MapDelete(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern)
{
Guard.Against.AnonymousMethod(handler);
builder.MapDelete(pattern, handler)
.WithName(handler.Method.Name);
return builder;
}
}

View File

@@ -0,0 +1,18 @@
using System.Reflection;
namespace Hutopy.Web.Infrastructure;
public static class MethodInfoExtensions
{
public static bool IsAnonymous(this MethodInfo method)
{
var invalidChars = new[] { '<', '>' };
return method.Name.Any(invalidChars.Contains);
}
public static void AnonymousMethod(this IGuardClause guardClause, Delegate input)
{
if (input.Method.IsAnonymous())
throw new ArgumentException("The endpoint name must be specified when using anonymous handlers.");
}
}

View File

@@ -0,0 +1,37 @@
using System.Reflection;
namespace Hutopy.Web.Infrastructure;
public static class WebApplicationExtensions
{
public static RouteGroupBuilder MapGroup(this WebApplication app, EndpointGroupBase group)
{
var groupName = group.GetType().Name;
return app
.MapGroup($"/api/{groupName}")
.WithGroupName(groupName)
.WithTags(groupName)
.WithOpenApi();
}
public static WebApplication MapEndpoints(this WebApplication app)
{
var endpointGroupType = typeof(EndpointGroupBase);
var assembly = Assembly.GetExecutingAssembly();
var endpointGroupTypes = assembly.GetExportedTypes()
.Where(t => t.IsSubclassOf(endpointGroupType));
foreach (var type in endpointGroupTypes)
{
if (Activator.CreateInstance(type) is EndpointGroupBase instance)
{
instance.Map(app);
}
}
return app;
}
}

View File

@@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

Some files were not shown because too many files have changed in this diff Show More