diff --git a/backend/.azure/bicep/main.bicep b/backend/.azure/bicep/main.bicep
new file mode 100644
index 0000000..c5582a9
--- /dev/null
+++ b/backend/.azure/bicep/main.bicep
@@ -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
diff --git a/backend/.editorconfig b/backend/.editorconfig
new file mode 100644
index 0000000..b6fb7e3
--- /dev/null
+++ b/backend/.editorconfig
@@ -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
diff --git a/backend/.github/FUNDING.yml b/backend/.github/FUNDING.yml
new file mode 100644
index 0000000..27f72a1
--- /dev/null
+++ b/backend/.github/FUNDING.yml
@@ -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']
diff --git a/backend/.github/workflows/build.yml b/backend/.github/workflows/build.yml
new file mode 100644
index 0000000..ce81956
--- /dev/null
+++ b/backend/.github/workflows/build.yml
@@ -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
diff --git a/backend/.github/workflows/cicd.yml b/backend/.github/workflows/cicd.yml
new file mode 100644
index 0000000..ce67ab4
--- /dev/null
+++ b/backend/.github/workflows/cicd.yml
@@ -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
\ No newline at end of file
diff --git a/backend/.github/workflows/deploy.yml b/backend/.github/workflows/deploy.yml
new file mode 100644
index 0000000..d9b02a8
--- /dev/null
+++ b/backend/.github/workflows/deploy.yml
@@ -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
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..d6fda7d
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,492 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from `dotnet new gitignore`
+
+# Ignore the main configuration file
+appsettings.json
+
+# 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
+
+# Other IDE files
+.vscode/
+.idea/
+
+
+#AppSettings dev
+*/appsettings.Development.json
+/src/Web/appsettings.Development.json
diff --git a/backend/.scripts/checks.ps1 b/backend/.scripts/checks.ps1
new file mode 100644
index 0000000..5dbd5df
--- /dev/null
+++ b/backend/.scripts/checks.ps1
@@ -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."
+}
\ No newline at end of file
diff --git a/backend/.scripts/cleanup.ps1 b/backend/.scripts/cleanup.ps1
new file mode 100644
index 0000000..824909d
--- /dev/null
+++ b/backend/.scripts/cleanup.ps1
@@ -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"
diff --git a/backend/.scripts/environments.json b/backend/.scripts/environments.json
new file mode 100644
index 0000000..917eb1b
--- /dev/null
+++ b/backend/.scripts/environments.json
@@ -0,0 +1,5 @@
+{
+ "Dev": "Development",
+ "Stg": "Staging",
+ "Prd": "Production"
+}
diff --git a/backend/.scripts/setup.ps1 b/backend/.scripts/setup.ps1
new file mode 100644
index 0000000..5bea571
--- /dev/null
+++ b/backend/.scripts/setup.ps1
@@ -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"
\ No newline at end of file
diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props
new file mode 100644
index 0000000..fad896c
--- /dev/null
+++ b/backend/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ false
+ enable
+ enable
+
+
\ No newline at end of file
diff --git a/backend/EfApplicationDbContext.ps1 b/backend/EfApplicationDbContext.ps1
new file mode 100644
index 0000000..da8240b
--- /dev/null
+++ b/backend/EfApplicationDbContext.ps1
@@ -0,0 +1 @@
+dotnet ef $args --startup-project ./src/Web/Web.csproj --project ./src/Infrastructure/Infrastructure.csproj
\ No newline at end of file
diff --git a/backend/EfContentDbContext.ps1 b/backend/EfContentDbContext.ps1
new file mode 100644
index 0000000..c484fd2
--- /dev/null
+++ b/backend/EfContentDbContext.ps1
@@ -0,0 +1 @@
+dotnet ef $args --startup-project ./src/Web/Web.csproj --project ./src/Web/Web.csproj --context ContentDbContext
\ No newline at end of file
diff --git a/backend/EfMessagingDbContext.ps1 b/backend/EfMessagingDbContext.ps1
new file mode 100644
index 0000000..6e5f39e
--- /dev/null
+++ b/backend/EfMessagingDbContext.ps1
@@ -0,0 +1 @@
+dotnet ef $args --startup-project ./src/Web/Web.csproj --project ./src/Web/Web.csproj --context MessagingDbContext
\ No newline at end of file
diff --git a/backend/Hutopy.sln b/backend/Hutopy.sln
new file mode 100644
index 0000000..8acad1f
--- /dev/null
+++ b/backend/Hutopy.sln
@@ -0,0 +1,43 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6ED356A7-8B47-4613-AD01-C85CF28491BD}"
+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
+ global.json = global.json
+ README.md = README.md
+ start-infrastructure.sh = start-infrastructure.sh
+ azure-pipelines.yml = azure-pipelines.yml
+ update-databases.sh = update-databases.sh
+ create-sql-scripts.sh = create-sql-scripts.sh
+ EndProjectSection
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Web", "src\Web\Web.csproj", "{4E4EE20C-F06A-4A1B-851F-C5577796941C}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {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
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {4E4EE20C-F06A-4A1B-851F-C5577796941C} = {6ED356A7-8B47-4613-AD01-C85CF28491BD}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {3CB609D9-5D54-4C11-A371-DAAC8B74E430}
+ EndGlobalSection
+EndGlobal
diff --git a/backend/Hutopy.sln.DotSettings b/backend/Hutopy.sln.DotSettings
new file mode 100644
index 0000000..140a817
--- /dev/null
+++ b/backend/Hutopy.sln.DotSettings
@@ -0,0 +1,2 @@
+
+ True
\ No newline at end of file
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..fcf7464
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,110 @@
+# Hutopy
+
+## Patterns / strategy used
+- Clean Architecture ( with Infrastructure, Domain, Application and Web layers )
+- Minimal API endpoints.
+
+## Tools
+- Install Docker : https://www.docker.com/get-started/
+- Install sql server management ( or preferred 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=P@ssword123!" -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=P@ssword123!' -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
+```
+
+## Postgres DB setup in docker for local dev
+```
+docker run -p 5432:5432 --name Hutopy -e POSTGRES_PASSWORD=P@ssword123! -e POSTGRES_USER=sa -d postgres
+
+
+```
+
+## Entity Framework
+
+Create a new migration :
+```
+./Ef.ps1 migrations add NomDeLaMigration
+```
+
+Update database :
+```
+./Ef.ps1 database update
+```
+
+## Secret Manager tool
+Go to Web project: cd src/Web
+
+Add a user secret for local development :
+```
+dotnet user-secrets set "DB_PASSWORD" "12345"
+```
+
+list your stored secrets :
+```
+dotnet user-secrets list
+```
+
+Delete a secret :
+```
+dotnet user-secrets remove "DB_PASSWORD"
+```
+
+## 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
+```
\ No newline at end of file
diff --git a/backend/Start.ps1 b/backend/Start.ps1
new file mode 100644
index 0000000..4724463
--- /dev/null
+++ b/backend/Start.ps1
@@ -0,0 +1,18 @@
+$password = $null
+
+# Manually parse arguments for a "-p" flag
+for ($i = 0; $i -lt $args.Count; $i++) {
+ if ($args[$i] -eq "-p" -and $args.Count -gt $i + 1) {
+ $password = $args[$i + 1]
+ break
+ }
+}
+
+# Check if we got a value for our environment variable
+if ($null -ne $password) {
+ $Env:DB_PASSWORD = $password
+}
+
+# Run the app in the web dir.
+Set-Location -Path ./src/Web
+dotnet watch run
\ No newline at end of file
diff --git a/backend/azure-pipelines.yml b/backend/azure-pipelines.yml
new file mode 100644
index 0000000..9ae04d1
--- /dev/null
+++ b/backend/azure-pipelines.yml
@@ -0,0 +1,34 @@
+# ASP.NET Core (.NET Framework)
+# Build and test ASP.NET Core projects targeting the full .NET Framework.
+# Add steps that publish symbols, save build artifacts, and more:
+# https://docs.microsoft.com/azure/devops/pipelines/languages/dotnet-core
+
+trigger:
+- master
+
+pool:
+ vmImage: 'windows-latest'
+
+variables:
+ solution: '**/*.sln'
+ buildPlatform: 'Any CPU'
+ buildConfiguration: 'Release'
+
+steps:
+- task: NuGetToolInstaller@1
+
+- task: NuGetCommand@2
+ inputs:
+ restoreSolution: '$(solution)'
+
+- task: VSBuild@1
+ inputs:
+ solution: '$(solution)'
+ msbuildArgs: '/p:DeployOnBuild=true /p:WebPublishMethod=Package /p:PackageAsSingleFile=true /p:SkipInvalidConfigurations=true /p:DesktopBuildPackageLocation="$(build.artifactStagingDirectory)\WebApp.zip" /p:DeployIisAppPath="Default Web Site"'
+ platform: '$(buildPlatform)'
+ configuration: '$(buildConfiguration)'
+
+- task: VSTest@2
+ inputs:
+ platform: '$(buildPlatform)'
+ configuration: '$(buildConfiguration)'
diff --git a/backend/create-sql-scripts.sh b/backend/create-sql-scripts.sh
new file mode 100644
index 0000000..4c0e3ee
--- /dev/null
+++ b/backend/create-sql-scripts.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+
+dotnet ef migrations script \
+ --startup-project src/Web/Web.csproj \
+ --project src/Web/Web.csproj \
+ --context Hutopy.Web.Features.Users.Data.IdentityDbContext \
+ --configuration Debug \
+ --output create-identity-db.sql \
+ --idempotent \
+ --no-build
+
+dotnet ef migrations script \
+ --startup-project src/Web/Web.csproj \
+ --project src/Web/Web.csproj \
+ --context Hutopy.Web.Features.Messages.Data.MessagingDbContext \
+ --configuration Debug \
+ --output create-messaging-db.sql \
+ --idempotent\
+ --no-build
+
+dotnet ef migrations script \
+ --startup-project src/Web/Web.csproj \
+ --project src/Web/Web.csproj \
+ --context Hutopy.Web.Features.Contents.Data.ContentDbContext \
+ --configuration Debug \
+ --output create-content-db.sql \
+ --idempotent \
+ --no-build
+
+dotnet ef migrations script \
+ --startup-project src/Web/Web.csproj \
+ --project src/Web/Web.csproj \
+ --context Hutopy.Web.Features.Memberships.Data.MembershipDbContext \
+ --configuration Debug \
+ --output create-membership-db.sql \
+ --idempotent \
+ --no-build
diff --git a/backend/global.json b/backend/global.json
new file mode 100644
index 0000000..18b689d
--- /dev/null
+++ b/backend/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "8.0.0",
+ "rollForward": "latestMinor",
+ "allowPrerelease": false
+ }
+}
diff --git a/backend/src/Web/.config/dotnet-tools.json b/backend/src/Web/.config/dotnet-tools.json
new file mode 100644
index 0000000..d9d129c
--- /dev/null
+++ b/backend/src/Web/.config/dotnet-tools.json
@@ -0,0 +1,12 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "8.0.3",
+ "commands": [
+ "dotnet-ef"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/src/Web/Common/BlobStorage/AzureBlobStorage.cs b/backend/src/Web/Common/BlobStorage/AzureBlobStorage.cs
new file mode 100644
index 0000000..0814f05
--- /dev/null
+++ b/backend/src/Web/Common/BlobStorage/AzureBlobStorage.cs
@@ -0,0 +1,147 @@
+using Azure;
+using Azure.Storage.Blobs;
+using Azure.Storage.Blobs.Models;
+
+namespace Hutopy.Web.Common.BlobStorage;
+
+public class AzureBlobStorage
+{
+ private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
+
+ private readonly BlobServiceClient _blobServiceClient;
+ private readonly ILogger _logger;
+
+ public AzureBlobStorage(IConfiguration configuration, ILogger logger)
+ {
+ _logger = logger;
+ var connectionString = configuration.GetConnectionString("AzureBlob");
+ _blobServiceClient = new BlobServiceClient(connectionString);
+ }
+
+ ///
+ /// Upload a file to microsoft azure blob storage.
+ ///
+ /// The name of the container where the file is stored.
+ /// The blob name (path within the container, include the file name).
+ ///
+ /// The content type.
+ /// The cancellation token
+ ///
+ public async Task UploadFileAsync(string containerName, string blobName, Stream stream,
+ string contentType, CancellationToken ct = default)
+ {
+ // Read the file stream into a memory stream to determine the length
+ // WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
+ stream.Position = 0;
+
+ // Check if the file size exceeds the maximum upload size
+ if (stream.Length > MaxUploadSize)
+ {
+ _logger.LogError(
+ $"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
+ throw new InvalidOperationException(
+ $"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
+ }
+
+ // Validate content type
+ if (!ContentTypes.IsAllowed(contentType, stream))
+ {
+ _logger.LogError(
+ $"Blob storage: Unsupported file type {contentType}.");
+ throw new InvalidOperationException("Unsupported file type.");
+ }
+
+ try
+ {
+ // Get a reference to a container
+ var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
+
+ // Create the container if it does not exist
+ await containerClient.CreateIfNotExistsAsync(
+ PublicAccessType.Blob,
+ cancellationToken: ct);
+
+ // Get a reference to a blob
+ var blobClient = containerClient.GetBlobClient(blobName);
+
+ // Define the BlobHttpHeaders to include the content type
+ var blobHttpHeaders = new BlobHttpHeaders { ContentType = contentType };
+
+ // Upload the file
+ var response = await blobClient.UploadAsync(
+ stream,
+ new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
+ ct);
+
+ var fileUri = blobClient.Uri.ToString();
+
+ _logger.LogInformation(
+ """
+ Blob storage: Status [ {ResponseStatus} ]
+ Uploaded [ {BlobName} ] to the container [ {ContainerName} ]
+ with contentType [ {ContentType} ]
+ with a length of [ {StreamLength} bytes ]
+ with the uri [ {FileUri} ]
+ """,
+ response.GetRawResponse().Status.ToString(),
+ blobName,
+ containerName,
+ contentType,
+ stream.Length,
+ fileUri
+ );
+
+ // Return the URI of the uploaded blob
+ return fileUri;
+ }
+ catch (RequestFailedException ex)
+ {
+ _logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError($"Blob storage: An error occurred: {ex.Message}");
+ throw;
+ }
+ }
+
+ ///
+ /// Download a file to microsoft's azure blob storage.
+ ///
+ /// The blob name (path within the container).
+ /// The name of the container where the file is stored. (users)
+ /// The cancellation token for the request
+ ///
+ public async Task DownloadFileAsync(string containerName, string blobName,
+ CancellationToken ct = default)
+ {
+ try
+ {
+ // Get a reference to a container
+ var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
+
+ // Get a reference to a blob
+ var blobClient = containerClient.GetBlobClient(blobName);
+
+ // Download the blob to a stream
+ BlobDownloadInfo download = await blobClient.DownloadAsync(ct);
+
+ MemoryStream memoryStream = new();
+ await download.Content.CopyToAsync(memoryStream, ct);
+ memoryStream.Position = 0; // Ensure the stream is at the beginning
+
+ return memoryStream;
+ }
+ catch (RequestFailedException ex)
+ {
+ _logger.LogError($"Azure Storage request failed: {ex.Message}");
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError($"An error occurred: {ex.Message}");
+ throw;
+ }
+ }
+}
diff --git a/backend/src/Web/Common/BlobStorage/BlobStructure.txt b/backend/src/Web/Common/BlobStorage/BlobStructure.txt
new file mode 100644
index 0000000..5410c95
--- /dev/null
+++ b/backend/src/Web/Common/BlobStorage/BlobStructure.txt
@@ -0,0 +1,33 @@
+users/
+│
+├── userId1/
+│ ├── profile/
+│ │ └── profilePicture.jpg
+│ │ └── data.json
+│ │
+│ ├── posts/
+│ │ ├── post1/
+│ │ │ ├── image1.jpg
+│ │ │ ├── video1.mp4
+│ │ │ └── audio1.mp3
+│ │ ├── post2/
+│ │ │ ├── image2.jpg
+│ │ │ └── video2.mp4
+│ │ └── ...
+│
+├── userId2/
+│ ├── profile/
+│ │ └── profilePicture.jpg
+│ │ └── data.json
+│ │
+│ ├── posts/
+│ │ ├── post1/
+│ │ │ ├── image1.jpg
+│ │ │ ├── video1.mp4
+│ │ │ └── audio1.mp3
+│ │ ├── post2/
+│ │ │ ├── image2.jpg
+│ │ │ └── video2.mp4
+│ │ └── ...
+│
+└── ...
diff --git a/backend/src/Web/Common/BlobStorage/CommonFileNames.cs b/backend/src/Web/Common/BlobStorage/CommonFileNames.cs
new file mode 100644
index 0000000..5d93f5b
--- /dev/null
+++ b/backend/src/Web/Common/BlobStorage/CommonFileNames.cs
@@ -0,0 +1,7 @@
+namespace Hutopy.Web.Common.BlobStorage;
+
+public static class CommonFileNames
+{
+ public static string ProfilePicture = "profilePicture";
+ public static string BannerPicture = "bannerPicture";
+}
diff --git a/backend/src/Web/Common/BlobStorage/ContainerNames.cs b/backend/src/Web/Common/BlobStorage/ContainerNames.cs
new file mode 100644
index 0000000..faedbe3
--- /dev/null
+++ b/backend/src/Web/Common/BlobStorage/ContainerNames.cs
@@ -0,0 +1,7 @@
+namespace Hutopy.Web.Common.BlobStorage;
+
+public static class ContainerNames
+{
+ public const string Users = "users";
+ public const string Creators = "creators";
+}
diff --git a/backend/src/Web/Common/BlobStorage/ContentTypes.cs b/backend/src/Web/Common/BlobStorage/ContentTypes.cs
new file mode 100644
index 0000000..a31a010
--- /dev/null
+++ b/backend/src/Web/Common/BlobStorage/ContentTypes.cs
@@ -0,0 +1,49 @@
+using System.Text;
+
+namespace Hutopy.Web.Common.BlobStorage;
+
+public static class ContentTypes
+{
+ private const string ImagePng = "image/png";
+ private const string ImageJpeg = "image/jpeg";
+ private const string ImageJpg = "image/jpg";
+ private const string TextHtml = "text/html";
+
+ private static readonly HashSet AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg, TextHtml];
+
+ public static bool IsAllowed(
+ string contentType,
+ Stream fileStream)
+ {
+ return IsValidFileType(fileStream) && AllowedContentTypes.Contains(contentType);
+ }
+
+ private static bool IsValidFileType(
+ Stream fileStream)
+ {
+ byte[] buffer = new byte[512];
+ _ = fileStream.Read(buffer, 0, buffer.Length);
+ fileStream.Position = 0;
+
+ // PNG file signature: 89 50 4E 47 (in hex)
+ if (buffer[0] == 0x89 && buffer[1] == 0x50 && buffer[2] == 0x4E && buffer[3] == 0x47)
+ {
+ return true;
+ }
+
+ // JPEG file signature: FF D8 FF (in hex)
+ if (buffer[0] == 0xFF && buffer[1] == 0xD8 && buffer[2] == 0xFF)
+ {
+ return true;
+ }
+
+ // Check for HTML content by looking for "" or "" tags
+ string content = Encoding.UTF8.GetString(buffer);
+ if (content.Contains(""))
+ {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/backend/src/Web/Common/BlobStorage/SubDirectoryNames.cs b/backend/src/Web/Common/BlobStorage/SubDirectoryNames.cs
new file mode 100644
index 0000000..b99383e
--- /dev/null
+++ b/backend/src/Web/Common/BlobStorage/SubDirectoryNames.cs
@@ -0,0 +1,7 @@
+namespace Hutopy.Web.Common.BlobStorage;
+
+public static class SubDirectoryNames
+{
+ public static string Profile = "profile";
+ public static string Contents = "contents";
+}
diff --git a/backend/src/Web/Common/GuidExtensions.cs b/backend/src/Web/Common/GuidExtensions.cs
new file mode 100644
index 0000000..c91b23f
--- /dev/null
+++ b/backend/src/Web/Common/GuidExtensions.cs
@@ -0,0 +1,94 @@
+namespace Hutopy.Web.Common;
+
+///
+/// Adapted from https://raw.githubusercontent.com/uuidjs/uuid/main/src/v7.ts.
+/// to match the uuid v7 generated on the client
+///
+public static class GuidHelper
+{
+ private class V7State
+ {
+ public long Msecs { get; set; } = long.MinValue;
+ public int Seq { get; set; }
+ }
+
+ private static readonly V7State State = new();
+ private static readonly Random Random = new();
+
+ public static Guid GenerateUuidV7()
+ {
+ byte[] randomValues = new byte[16];
+ Random.NextBytes(randomValues);
+
+ UpdateV7State(
+ State,
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ randomValues);
+
+ var values = V7Bytes(randomValues, State.Msecs, State.Seq);
+
+ return new Guid(values);
+ }
+
+ private static void UpdateV7State(V7State state, long now, byte[] randomBytes)
+ {
+ if (now > state.Msecs)
+ {
+ state.Seq = (randomBytes[6] << 23) | (randomBytes[7] << 16) | (randomBytes[8] << 8) | randomBytes[9];
+ state.Msecs = now;
+ }
+ else
+ {
+ state.Seq = (state.Seq + 1) | 0;
+ if (state.Seq == 0)
+ {
+ state.Msecs++;
+ }
+ }
+ }
+
+ private static byte[] V7Bytes(byte[] randomBytes, long? msecs = null, int? seq = null, byte[]? buf = null, int offset = 0)
+ {
+ if (buf == null)
+ {
+ buf = new byte[16];
+ offset = 0;
+ }
+
+ // Defaults
+ msecs ??= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ seq ??= ((randomBytes[6] & 0x7f) << 24) | (randomBytes[7] << 16) | (randomBytes[8] << 8) | randomBytes[9];
+
+ // byte 0-5: timestamp (48 bits)
+ buf[offset++] = (byte)((msecs.Value / 0x10000000000) & 0xff);
+ buf[offset++] = (byte)((msecs.Value / 0x100000000) & 0xff);
+ buf[offset++] = (byte)((msecs.Value / 0x1000000) & 0xff);
+ buf[offset++] = (byte)((msecs.Value / 0x10000) & 0xff);
+ buf[offset++] = (byte)((msecs.Value / 0x100) & 0xff);
+ buf[offset++] = (byte)(msecs.Value & 0xff);
+
+ // byte 6: `version` (4 bits) | sequence bits 28-31 (4 bits)
+ buf[offset++] = (byte)(0x70 | ((seq.Value >> 28) & 0x0f));
+
+ // byte 7: sequence bits 20-27 (8 bits)
+ buf[offset++] = (byte)((seq.Value >> 20) & 0xff);
+
+ // byte 8: `variant` (2 bits) | sequence bits 14-19 (6 bits)
+ buf[offset++] = (byte)(0x80 | ((seq.Value >> 14) & 0x3f));
+
+ // byte 9: sequence bits 6-13 (8 bits)
+ buf[offset++] = (byte)((seq.Value >> 6) & 0xff);
+
+ // byte 10: sequence bits 0-5 (6 bits) | random (2 bits)
+ buf[offset++] = (byte)(((seq.Value << 2) & 0xff) | (randomBytes[10] & 0x03));
+
+ // bytes 11-15: random (40 bits)
+ buf[offset++] = randomBytes[11];
+ buf[offset++] = randomBytes[12];
+ buf[offset++] = randomBytes[13];
+ buf[offset++] = randomBytes[14];
+ buf[offset] = randomBytes[15];
+
+ return buf;
+ }
+}
diff --git a/backend/src/Web/Common/Security/ClaimsPrincipalExtensions.cs b/backend/src/Web/Common/Security/ClaimsPrincipalExtensions.cs
new file mode 100644
index 0000000..07ac0c3
--- /dev/null
+++ b/backend/src/Web/Common/Security/ClaimsPrincipalExtensions.cs
@@ -0,0 +1,63 @@
+using System.Security.Claims;
+
+namespace Hutopy.Web.Common.Security;
+
+public static class ClaimsPrincipalExtensions
+{
+ public static Guid GetUserId(this ClaimsPrincipal claims)
+ {
+ return (Guid)claims.GetRequiredClaim(ClaimTypes.NameIdentifier);
+ }
+
+ public static string GetName(this ClaimsPrincipal claims)
+ {
+ return (string)claims.GetRequiredClaim(ClaimTypes.Name);
+ }
+
+ public static string? GetAlias(this ClaimsPrincipal claims)
+ {
+ return (string?)claims.GetClaim(KnownClaims.Alias);
+ }
+
+ public static string? GetPortraitUrl(this ClaimsPrincipal claims)
+ {
+ return (string?)claims.GetClaim(KnownClaims.PortraitUrl);
+ }
+
+ public static string GetFirstName(this ClaimsPrincipal claims)
+ {
+ return (string)claims.GetRequiredClaim(ClaimTypes.GivenName);
+ }
+
+ public static string GetLastName(this ClaimsPrincipal claims)
+ {
+ return (string)claims.GetRequiredClaim(ClaimTypes.Surname);
+ }
+
+ public static string GetEmail(this ClaimsPrincipal claims)
+ {
+ return (string)claims.GetRequiredClaim(ClaimTypes.Email);
+ }
+
+ private static object? GetClaim(this ClaimsPrincipal claims, string key)
+ {
+ var claim = claims.FindFirst(key);
+
+ if (claim is null) return default;
+ return claims.GetRequiredClaim(key);
+ }
+
+ private static object GetRequiredClaim(this ClaimsPrincipal claims, string key)
+ {
+ var claim = claims.FindFirst(key);
+
+ if (claim is null) throw new MissingClaimException(key);
+
+ if (typeof(TValue) == typeof(Guid))
+ {
+ return Guid.Parse(claim.Value);
+ }
+
+ return Convert.ChangeType(claim.Value, typeof(TValue));
+ }
+}
diff --git a/backend/src/Web/Common/Security/GenerateJwtToken.cs b/backend/src/Web/Common/Security/GenerateJwtToken.cs
new file mode 100644
index 0000000..ed301b6
--- /dev/null
+++ b/backend/src/Web/Common/Security/GenerateJwtToken.cs
@@ -0,0 +1,53 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Hutopy.Web.Common.Security;
+
+public static class JwtTokenHelper
+{
+ public static string GenerateJwtToken(
+ TimeSpan expiresIn,
+ string issuer,
+ string audience,
+ string key,
+ string userId,
+ string email,
+ string? alias,
+ string firstname,
+ string lastname,
+ string? portraitUrl)
+ {
+ var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
+ var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
+
+ var claims = new List(new[]
+ {
+ new Claim(JwtRegisteredClaimNames.Sub, userId),
+ new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
+ new Claim(ClaimTypes.NameIdentifier, userId), new Claim(ClaimTypes.Email, email),
+ new Claim(ClaimTypes.Name, email), new Claim(ClaimTypes.GivenName, firstname),
+ new Claim(ClaimTypes.Surname, lastname)
+ });
+
+ if (alias is not null)
+ {
+ claims.Add(new(KnownClaims.Alias, alias));
+ }
+
+ if (portraitUrl is not null)
+ {
+ claims.Add(new(KnownClaims.PortraitUrl, portraitUrl));
+ }
+
+ var token = new JwtSecurityToken(
+ issuer: issuer,
+ audience: audience,
+ claims: claims,
+ expires: DateTime.Now.Add(expiresIn),
+ signingCredentials: credentials);
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+}
diff --git a/backend/src/Web/Common/Security/KnownClaims.cs b/backend/src/Web/Common/Security/KnownClaims.cs
new file mode 100644
index 0000000..9c33719
--- /dev/null
+++ b/backend/src/Web/Common/Security/KnownClaims.cs
@@ -0,0 +1,7 @@
+namespace Hutopy.Web.Common.Security;
+
+public static class KnownClaims
+{
+ public const string Alias = "alias";
+ public const string PortraitUrl = "portraitUrl";
+}
diff --git a/backend/src/Web/Common/Security/MissingClaimException.cs b/backend/src/Web/Common/Security/MissingClaimException.cs
new file mode 100644
index 0000000..f2b6126
--- /dev/null
+++ b/backend/src/Web/Common/Security/MissingClaimException.cs
@@ -0,0 +1,5 @@
+namespace Hutopy.Web.Common.Security;
+
+public class MissingClaimException(
+ string claimName)
+ : Exception;
diff --git a/backend/src/Web/Common/Security/PasswordGenerator.cs b/backend/src/Web/Common/Security/PasswordGenerator.cs
new file mode 100644
index 0000000..e4ee290
--- /dev/null
+++ b/backend/src/Web/Common/Security/PasswordGenerator.cs
@@ -0,0 +1,70 @@
+using System.Text;
+
+namespace Hutopy.Web.Common.Security;
+
+// If we need to add special characters we can alternate between 2 pools.
+public static class PasswordGenerator
+{
+ private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz";
+ private const string UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ private const string Numbers = "0123456789";
+ private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?";
+
+ private static readonly Random Random = new();
+
+ public static string GeneratePassword(
+ int minLength,
+ int maxLength,
+ bool requireNumber = true,
+ bool requireCapital = true,
+ bool requireSpecialCharacter = true)
+ {
+ // Create pools based on the requirements
+ var characterPool = new StringBuilder(LowerLetters);
+
+ if (requireCapital)
+ characterPool.Append(UpperLetters);
+
+ if (requireNumber)
+ characterPool.Append(Numbers);
+
+ if (requireSpecialCharacter)
+ characterPool.Append(SpecialCharacters);
+
+ // Ensure that the length is within the specified bounds
+ int length = Random.Next(minLength, maxLength + 1);
+ var password = new char[length];
+
+ // Ensure at least one character from each required category is included
+ int index = 0;
+
+ if (requireCapital)
+ password[index++] = UpperLetters[Random.Next(UpperLetters.Length)];
+
+ if (requireNumber)
+ password[index++] = Numbers[Random.Next(Numbers.Length)];
+
+ if (requireSpecialCharacter)
+ password[index++] = SpecialCharacters[Random.Next(SpecialCharacters.Length)];
+
+ // Fill the rest of the password
+ for (int i = index; i < length; i++)
+ {
+ password[i] = characterPool[Random.Next(characterPool.Length)];
+ }
+
+ // Shuffle the password to randomize the placement of the required characters
+ Shuffle(password);
+ return new string(password);
+ }
+
+ private static void Shuffle(
+ char[] array)
+ {
+ for (int i = array.Length - 1; i > 0; i--)
+ {
+ int j = Random.Next(i + 1);
+ (array[i], array[j]) = (array[j], array[i]); // Swap elements
+ }
+ }
+}
diff --git a/backend/src/Web/Controllers/FacebookController.cs b/backend/src/Web/Controllers/FacebookController.cs
new file mode 100644
index 0000000..d05c3f3
--- /dev/null
+++ b/backend/src/Web/Controllers/FacebookController.cs
@@ -0,0 +1,60 @@
+using System.Security.Claims;
+using Hutopy.Web.Common;
+using Hutopy.Web.Common.Security;
+using Hutopy.Web.Features.Users;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.Facebook;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Hutopy.Web.Controllers;
+
+public class FacebookController(
+ IdentityService identityService)
+ : Controller
+{
+ [Microsoft.AspNetCore.Mvc.HttpGet("/api/facebook/sign-in")]
+ public async Task SignIn()
+ {
+ await HttpContext.ChallengeAsync(FacebookDefaults.AuthenticationScheme,
+ new AuthenticationProperties { RedirectUri = Url.Action("Authorize") });
+ }
+
+ public async Task Authorize()
+ {
+ var authenticateResult = await HttpContext.AuthenticateAsync(FacebookDefaults.AuthenticationScheme);
+
+ if (!authenticateResult.Succeeded) return BadRequest();
+
+ var claims = authenticateResult.Principal.Claims.ToList();
+
+ var name = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? "";
+ var email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value ?? "";
+ var givenName = claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value ?? "";
+ var familyName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value ?? "";
+
+ var claimsIdentity = new ClaimsIdentity(
+ new List
+ {
+ new(ClaimTypes.Name, name),
+ new(ClaimTypes.Email, email),
+ new(ClaimTypes.GivenName, givenName),
+ new(ClaimTypes.Surname, familyName)
+ },
+ CookieAuthenticationDefaults.AuthenticationScheme);
+
+ if (await identityService.FindUserByEmailAsync(email) != null)
+ {
+ await HttpContext.SignInAsync(
+ CookieAuthenticationDefaults.AuthenticationScheme,
+ new ClaimsPrincipal(claimsIdentity));
+ return Redirect("/");
+ }
+
+ await identityService.CreateUserAsync(email, givenName, givenName, familyName,
+ PasswordGenerator.GeneratePassword(8, 10));
+ await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
+ new ClaimsPrincipal(claimsIdentity));
+ return Redirect("/");
+ }
+}
diff --git a/backend/src/Web/DependencyInjection.cs b/backend/src/Web/DependencyInjection.cs
new file mode 100644
index 0000000..a6490d8
--- /dev/null
+++ b/backend/src/Web/DependencyInjection.cs
@@ -0,0 +1,110 @@
+using System.Text;
+using Azure.Identity;
+using Hutopy.Web.Features.Users.Data;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.Facebook;
+using Microsoft.AspNetCore.Authentication.Google;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Hutopy.Web;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddWebServices(this IServiceCollection services)
+ {
+ services.AddDatabaseDeveloperPageExceptionFilter();
+
+ services.AddHttpContextAccessor();
+
+ services.AddHealthChecks()
+ .AddDbContextCheck();
+
+ services.AddRazorPages();
+
+ services.AddHttpClient();
+
+ // Customise default API behaviour
+ services.Configure(options =>
+ options.SuppressModelStateInvalidFilter = true);
+
+ services.AddEndpointsApiExplorer();
+
+ 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;
+ }
+
+ public static IServiceCollection AddAuthorizationAndAuthentication(this IServiceCollection services,
+ ConfigurationManager configuration)
+ {
+ var authenticationBuilder = services
+ .AddAuthentication(options =>
+ {
+ options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
+ })
+ .AddCookie("Identity.Application", options =>
+ {
+ options.LoginPath = "/api/Users/login";
+ });
+
+ var authJwt = configuration.GetSection("Authentication:Jwt");
+ if (authJwt.Exists())
+ {
+ authenticationBuilder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions =>
+ {
+ jwtBearerOptions.Authority = "https://hutopy.com";
+ jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = authJwt["Issuer"],
+ ValidateAudience = true,
+ ValidAudience = authJwt["Audience"],
+ ValidateLifetime = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authJwt["Key"] ??
+ throw new ArgumentNullException("The Jwt Key is missing.")))
+ };
+ });
+ }
+
+ var authGoogle = configuration.GetSection("Authentication:Google");
+ if (authGoogle.Exists())
+ {
+ authenticationBuilder.AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
+ {
+ options.ClientId = authGoogle["ClientId"] ??
+ throw new ArgumentNullException("The Google ClientId is missing.");
+ options.ClientSecret = authGoogle["ClientSecret"] ??
+ throw new ArgumentNullException("The Google ClientSecret is missing.");
+ });
+ }
+
+ var authFacebook = configuration.GetSection("Authentication:Facebook");
+ if (authFacebook.Exists())
+ {
+ authenticationBuilder.AddFacebook(FacebookDefaults.AuthenticationScheme, options =>
+ {
+ options.ClientId = authFacebook["ClientId"] ??
+ throw new ArgumentNullException("The Facebook ClientId is missing.");
+ options.ClientSecret = authFacebook["ClientSecret"] ??
+ throw new ArgumentNullException("The Facebook ClientSecret is missing.");
+ });
+ }
+
+ return services;
+ }
+}
diff --git a/backend/src/Web/Extensions/EnumExtensions.cs b/backend/src/Web/Extensions/EnumExtensions.cs
new file mode 100644
index 0000000..c820a5e
--- /dev/null
+++ b/backend/src/Web/Extensions/EnumExtensions.cs
@@ -0,0 +1,34 @@
+namespace Hutopy.Web.Extensions;
+
+public static class EnumExtensions
+{
+ ///
+ /// Converts a string to the specified enum type.
+ ///
+ /// The type of the enum to convert to. Must be an enum.
+ /// The string value to convert.
+ /// Specifies whether the string comparison should ignore case. Default is true.
+ ///
+ /// The corresponding enum value if the conversion is successful; otherwise, null if the string
+ /// cannot be converted to the specified enum type.
+ ///
+ public static TEnum? ToEnum(this string value, bool ignoreCase = true) where TEnum : struct
+ {
+ if (Enum.TryParse(value, ignoreCase, out TEnum result))
+ {
+ return result;
+ }
+ return null;
+ }
+
+ ///
+ /// Converts an enum value to its string representation.
+ ///
+ /// The type of the enum.
+ /// The enum value to convert.
+ /// The string representation of the enum value.
+ public static string FromEnum(this TEnum enumValue) where TEnum : struct, Enum
+ {
+ return enumValue.ToString();
+ }
+}
diff --git a/backend/src/Web/Features/Contents/Data/Content.cs b/backend/src/Web/Features/Contents/Data/Content.cs
new file mode 100644
index 0000000..6198f8b
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/Content.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Hutopy.Web.Features.Contents.Data;
+
+public class Content
+{
+ public Guid Id { get; init; }
+ public Guid CreatedBy { get; init; }
+ public Creator? Creator { get; set; }
+ public DateTimeOffset CreatedAt { get; init; }
+ public Guid? DeletedBy { get; set; }
+ public DateTimeOffset? DeletedAt { get; set; }
+ [MaxLength(128)] public required string Title { get; set; }
+
+ [MaxLength(512)] public string? ThumbnailUrl { get; set; } = "";
+ [MaxLength(2048)] public string Description { get; set; } = "";
+ [MaxLength(2048)] public string? HtmlFileUrl { get; set; } = "";
+ public IList Reactions { get; set; } = new List();
+ public string[]? Urls { get; init; }
+
+}
diff --git a/backend/src/Web/Features/Contents/Data/ContentDbContext.cs b/backend/src/Web/Features/Contents/Data/ContentDbContext.cs
new file mode 100644
index 0000000..6ef76de
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/ContentDbContext.cs
@@ -0,0 +1,68 @@
+namespace Hutopy.Web.Features.Contents.Data;
+
+public class ContentDbContext(
+ DbContextOptions options)
+ : DbContext(options)
+{
+ public const string SchemaName = "Content";
+
+ public DbSet Contents => Set();
+ public DbSet Creators => Set();
+
+ protected override void OnModelCreating(
+ ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema(SchemaName);
+
+ modelBuilder
+ .Entity()
+ .Property(c => c.CreatedAt)
+ .ValueGeneratedOnAdd()
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ modelBuilder
+ .Entity()
+ .HasOne(c => c.Creator)
+ .WithMany()
+ .HasForeignKey(c => c.CreatedBy);
+
+ modelBuilder
+ .Entity()
+ .OwnsMany(c => c.Reactions)
+ .ToTable("Reactions");
+
+ modelBuilder
+ .Entity()
+ .Property(c => c.ThumbnailUrl);
+
+ modelBuilder
+ .Entity()
+ .Property(x => x.NormalizedName)
+ .HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", stored: true);
+
+ modelBuilder
+ .Entity()
+ .HasIndex(x => x.NormalizedName)
+ .IsUnique();
+
+ modelBuilder
+ .Entity()
+ .OwnsOne(x => x.Socials)
+ .ToTable(nameof(Socials));
+
+ modelBuilder
+ .Entity()
+ .OwnsOne(x => x.Colors)
+ .ToTable(nameof(Colors));
+
+ modelBuilder
+ .Entity()
+ .OwnsOne(x => x.Images)
+ .ToTable(nameof(Images));
+
+ modelBuilder
+ .Entity()
+ .OwnsOne(x => x.PresentationInfos)
+ .ToTable(nameof(PresentationInfos));
+ }
+}
diff --git a/backend/src/Web/Features/Contents/Data/ContentDbContextInitializer.cs b/backend/src/Web/Features/Contents/Data/ContentDbContextInitializer.cs
new file mode 100644
index 0000000..571e8f5
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/ContentDbContextInitializer.cs
@@ -0,0 +1,32 @@
+namespace Hutopy.Web.Features.Contents.Data;
+
+public static class InitializerExtensions
+{
+ public static async Task InitialiseContentDbContextAsync(this WebApplication app)
+ {
+ using var scope = app.Services.CreateScope();
+
+ var initializer = scope.ServiceProvider.GetRequiredService();
+
+ await initializer.InitialiseAsync();
+ }
+}
+
+public class ContentDbContextInitializer(
+ ILogger logger,
+ ContentDbContext context
+ )
+{
+ public async Task InitialiseAsync()
+ {
+ try
+ {
+ await context.Database.MigrateAsync();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "An error occurred while initialising the content database.");
+ throw;
+ }
+ }
+}
diff --git a/backend/src/Web/Features/Contents/Data/ContentReaction.cs b/backend/src/Web/Features/Contents/Data/ContentReaction.cs
new file mode 100644
index 0000000..eec8fe4
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/ContentReaction.cs
@@ -0,0 +1,11 @@
+using System.ComponentModel.DataAnnotations;
+using Hutopy.Web.Features.Contents.Data.Enums;
+
+namespace Hutopy.Web.Features.Contents.Data;
+
+public class ContentReaction
+{
+ public required Reaction Reaction { get; set; }
+ public required Guid UserId { get; set; }
+ [MaxLength(128)] public required string UserName { get; set; }
+}
diff --git a/backend/src/Web/Features/Contents/Data/Creator.cs b/backend/src/Web/Features/Contents/Data/Creator.cs
new file mode 100644
index 0000000..496a4dc
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/Creator.cs
@@ -0,0 +1,72 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Hutopy.Web.Features.Contents.Data;
+
+public class Creator
+{
+ public Guid Id { get; set; }
+ public Guid CreatedBy { get; set; }
+ public DateTimeOffset CreatedAt { get; init; }
+ public bool AcceptDonation { get; set; }
+ public bool Verified { get; set; }
+ [MaxLength(255)] public string Name { get; set; } = null!;
+ [MaxLength(255)] public string NormalizedName { get; set; } = null!;
+ [MaxLength(255)] public string? Title { get; set; }
+ public Socials Socials { get; set; } = new();
+ public Colors Colors { get; set; } = new();
+ public Images Images { get; set; } = new();
+ public PresentationInfos PresentationInfos { get; set; } = new();
+}
+
+public class Colors
+{
+ [MaxLength(9)] public string Primary { get; set; } = null!;
+ [MaxLength(9)] public string Secondary { get; set; } = null!;
+ [MaxLength(9)] public string Background { get; set; } = null!;
+ [MaxLength(9)] public string Surface { get; set; } = null!;
+ [MaxLength(9)] public string Error { get; set; } = null!;
+ [MaxLength(9)] public string OnPrimary { get; set; } = null!;
+ [MaxLength(9)] public string OnSecondary { get; set; } = null!;
+ [MaxLength(9)] public string OnBackground { get; set; } = null!;
+ [MaxLength(9)] public string OnSurface { get; set; } = null!;
+ [MaxLength(9)] public string OnError { get; set; } = null!;
+}
+
+public class Socials
+{
+ [MaxLength(255)] public string? FacebookUrl { get; set; }
+ [MaxLength(255)] public string? InstagramUrl { get; set; }
+ [MaxLength(255)] public string? XUrl { get; set; }
+ [MaxLength(255)] public string? LinkedInUrl { get; set; }
+ [MaxLength(255)] public string? TikTokUrl { get; set; }
+ [MaxLength(255)] public string? YoutubeUrl { get; set; }
+ [MaxLength(255)] public string? RedditUrl { get; set; }
+ [MaxLength(255)] public string? WebsiteUrl { get; set; }
+}
+
+public class Images
+{
+ [MaxLength(255)] public string? Banner { get; set; }
+ [MaxLength(255)] public string? Logo { get; set; }
+}
+
+public class PresentationInfos
+{
+ [MaxLength(255)] public string PhoneNumber { get; set; } = string.Empty;
+ [MaxLength(255)] public string Email { get; set; } = string.Empty;
+ [MaxLength(2000)] public string Title { get; set; } = string.Empty;
+ [MaxLength(2000)] public string MainImageUrl { get; set; } = string.Empty;
+ [MaxLength(10000)] public string MainImageText { get; set; } = string.Empty;
+ [MaxLength(10000)] public string MainVideoText { get; set; } = string.Empty;
+ [MaxLength(2000)] public string ImagesSubtitle { get; set; } = string.Empty;
+ [MaxLength(2000)] public string Image1Url { get; set; } = string.Empty;
+ [MaxLength(2000)] public string Image2Url { get; set; } = string.Empty;
+ [MaxLength(2000)] public string Image3Url { get; set; } = string.Empty;
+ [MaxLength(2000)] public string Image4Url { get; set; } = string.Empty;
+ [MaxLength(10000)] public string ImagesText { get; set; } = string.Empty;
+ [MaxLength(2000)] public string VideoSubtitle { get; set; } = string.Empty;
+ [MaxLength(2000)] public string VideoSubtitleMain { get; set; } = string.Empty;
+ [MaxLength(2000)] public string VideoUrlMain { get; set; } = string.Empty;
+ [MaxLength(2000)] public string VideoUrl { get; set; } = string.Empty;
+ [MaxLength(10000)] public string VideoText { get; set; } = string.Empty;
+}
diff --git a/backend/src/Web/Features/Contents/Data/Enums/Reaction.cs b/backend/src/Web/Features/Contents/Data/Enums/Reaction.cs
new file mode 100644
index 0000000..4617ffa
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/Enums/Reaction.cs
@@ -0,0 +1,13 @@
+namespace Hutopy.Web.Features.Contents.Data.Enums;
+
+public enum Reaction
+{
+ None = 0,
+ Like = 1,
+ Dislike = 2,
+ Love = 3,
+ Haha = 4,
+ Wow = 5,
+ Sad = 6,
+ Angry = 7
+}
diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20241020202641_Initial.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20241020202641_Initial.Designer.cs
new file mode 100644
index 0000000..95015b2
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/Migrations/20241020202641_Initial.Designer.cs
@@ -0,0 +1,285 @@
+//
+using System;
+using Hutopy.Web.Features.Contents.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Hutopy.Web.Features.Contents.Data.Migrations
+{
+ [DbContext(typeof(ContentDbContext))]
+ [Migration("20241020202641_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("Content")
+ .HasAnnotation("ProductVersion", "8.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("HtmlFileUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Urls")
+ .HasColumnType("text[]");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedBy");
+
+ b.ToTable("Contents", "Content");
+ });
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Title")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Creators", "Content");
+ });
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
+ {
+ b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
+ {
+ b1.Property("ContentId")
+ .HasColumnType("uuid");
+
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id"));
+
+ b1.Property("Reaction")
+ .HasColumnType("integer");
+
+ b1.Property("UserId")
+ .HasColumnType("uuid");
+
+ b1.Property("UserName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b1.HasKey("ContentId", "Id");
+
+ b1.ToTable("Reactions", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("ContentId");
+ });
+
+ b.Navigation("Creator");
+
+ b.Navigation("Reactions");
+ });
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
+ {
+ b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
+ {
+ b1.Property("CreatorId")
+ .HasColumnType("uuid");
+
+ b1.Property("Background")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Error")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnBackground")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnError")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnPrimary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnSecondary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnSurface")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Primary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Secondary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Surface")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.HasKey("CreatorId");
+
+ b1.ToTable("Colors", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("CreatorId");
+ });
+
+ b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
+ {
+ b1.Property("CreatorId")
+ .HasColumnType("uuid");
+
+ b1.Property("Banner")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("Logo")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.HasKey("CreatorId");
+
+ b1.ToTable("Images", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("CreatorId");
+ });
+
+ b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
+ {
+ b1.Property("CreatorId")
+ .HasColumnType("uuid");
+
+ b1.Property("FacebookUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("InstagramUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("LinkedInUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("RedditUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("TikTokUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("WebsiteUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("XUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("YoutubeUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.HasKey("CreatorId");
+
+ b1.ToTable("Socials", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("CreatorId");
+ });
+
+ b.Navigation("Colors")
+ .IsRequired();
+
+ b.Navigation("Images")
+ .IsRequired();
+
+ b.Navigation("Socials")
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20241020202641_Initial.cs b/backend/src/Web/Features/Contents/Data/Migrations/20241020202641_Initial.cs
new file mode 100644
index 0000000..37b0eb8
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/Migrations/20241020202641_Initial.cs
@@ -0,0 +1,197 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Hutopy.Web.Features.Contents.Data.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "Content");
+
+ migrationBuilder.CreateTable(
+ name: "Creators",
+ schema: "Content",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ CreatedBy = table.Column(type: "uuid", nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ Name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ Title = table.Column(type: "character varying(255)", maxLength: 255, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Creators", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Colors",
+ schema: "Content",
+ columns: table => new
+ {
+ CreatorId = table.Column(type: "uuid", nullable: false),
+ Primary = table.Column(type: "character varying(9)", maxLength: 9, nullable: false),
+ Secondary = table.Column(type: "character varying(9)", maxLength: 9, nullable: false),
+ Background = table.Column(type: "character varying(9)", maxLength: 9, nullable: false),
+ Surface = table.Column(type: "character varying(9)", maxLength: 9, nullable: false),
+ Error = table.Column(type: "character varying(9)", maxLength: 9, nullable: false),
+ OnPrimary = table.Column(type: "character varying(9)", maxLength: 9, nullable: false),
+ OnSecondary = table.Column(type: "character varying(9)", maxLength: 9, nullable: false),
+ OnBackground = table.Column(type: "character varying(9)", maxLength: 9, nullable: false),
+ OnSurface = table.Column(type: "character varying(9)", maxLength: 9, nullable: false),
+ OnError = table.Column(type: "character varying(9)", maxLength: 9, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Colors", x => x.CreatorId);
+ table.ForeignKey(
+ name: "FK_Colors_Creators_CreatorId",
+ column: x => x.CreatorId,
+ principalSchema: "Content",
+ principalTable: "Creators",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Contents",
+ schema: "Content",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ CreatedBy = table.Column(type: "uuid", nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
+ DeletedBy = table.Column(type: "uuid", nullable: true),
+ DeletedAt = table.Column(type: "timestamp with time zone", nullable: true),
+ Title = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ Description = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false),
+ HtmlFileUrl = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true),
+ Urls = table.Column(type: "text[]", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Contents", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Contents_Creators_CreatedBy",
+ column: x => x.CreatedBy,
+ principalSchema: "Content",
+ principalTable: "Creators",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Images",
+ schema: "Content",
+ columns: table => new
+ {
+ CreatorId = table.Column(type: "uuid", nullable: false),
+ Banner = table.Column(type: "character varying(255)", maxLength: 255, nullable: true),
+ Logo = table.Column(type: "character varying(255)", maxLength: 255, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Images", x => x.CreatorId);
+ table.ForeignKey(
+ name: "FK_Images_Creators_CreatorId",
+ column: x => x.CreatorId,
+ principalSchema: "Content",
+ principalTable: "Creators",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Socials",
+ schema: "Content",
+ columns: table => new
+ {
+ CreatorId = table.Column(type: "uuid", nullable: false),
+ FacebookUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: true),
+ InstagramUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: true),
+ XUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: true),
+ LinkedInUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: true),
+ TikTokUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: true),
+ YoutubeUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: true),
+ RedditUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: true),
+ WebsiteUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Socials", x => x.CreatorId);
+ table.ForeignKey(
+ name: "FK_Socials_Creators_CreatorId",
+ column: x => x.CreatorId,
+ principalSchema: "Content",
+ principalTable: "Creators",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Reactions",
+ schema: "Content",
+ columns: table => new
+ {
+ ContentId = table.Column(type: "uuid", nullable: false),
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ Reaction = table.Column(type: "integer", nullable: false),
+ UserId = table.Column(type: "uuid", nullable: false),
+ UserName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Reactions", x => new { x.ContentId, x.Id });
+ table.ForeignKey(
+ name: "FK_Reactions_Contents_ContentId",
+ column: x => x.ContentId,
+ principalSchema: "Content",
+ principalTable: "Contents",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Contents_CreatedBy",
+ schema: "Content",
+ table: "Contents",
+ column: "CreatedBy");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Colors",
+ schema: "Content");
+
+ migrationBuilder.DropTable(
+ name: "Images",
+ schema: "Content");
+
+ migrationBuilder.DropTable(
+ name: "Reactions",
+ schema: "Content");
+
+ migrationBuilder.DropTable(
+ name: "Socials",
+ schema: "Content");
+
+ migrationBuilder.DropTable(
+ name: "Contents",
+ schema: "Content");
+
+ migrationBuilder.DropTable(
+ name: "Creators",
+ schema: "Content");
+ }
+ }
+}
diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20241201173048_AddThumbnailUrl.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20241201173048_AddThumbnailUrl.Designer.cs
new file mode 100644
index 0000000..02bab5f
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/Migrations/20241201173048_AddThumbnailUrl.Designer.cs
@@ -0,0 +1,289 @@
+//
+using System;
+using Hutopy.Web.Features.Contents.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Hutopy.Web.Features.Contents.Data.Migrations
+{
+ [DbContext(typeof(ContentDbContext))]
+ [Migration("20241201173048_AddThumbnailUrl")]
+ partial class AddThumbnailUrl
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("Content")
+ .HasAnnotation("ProductVersion", "8.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("HtmlFileUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("ThumbnailUrl")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Urls")
+ .HasColumnType("text[]");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedBy");
+
+ b.ToTable("Contents", "Content");
+ });
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Title")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Creators", "Content");
+ });
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
+ {
+ b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
+ {
+ b1.Property("ContentId")
+ .HasColumnType("uuid");
+
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id"));
+
+ b1.Property("Reaction")
+ .HasColumnType("integer");
+
+ b1.Property("UserId")
+ .HasColumnType("uuid");
+
+ b1.Property("UserName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b1.HasKey("ContentId", "Id");
+
+ b1.ToTable("Reactions", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("ContentId");
+ });
+
+ b.Navigation("Creator");
+
+ b.Navigation("Reactions");
+ });
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
+ {
+ b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
+ {
+ b1.Property("CreatorId")
+ .HasColumnType("uuid");
+
+ b1.Property("Background")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Error")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnBackground")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnError")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnPrimary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnSecondary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnSurface")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Primary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Secondary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Surface")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.HasKey("CreatorId");
+
+ b1.ToTable("Colors", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("CreatorId");
+ });
+
+ b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
+ {
+ b1.Property("CreatorId")
+ .HasColumnType("uuid");
+
+ b1.Property("Banner")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("Logo")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.HasKey("CreatorId");
+
+ b1.ToTable("Images", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("CreatorId");
+ });
+
+ b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
+ {
+ b1.Property("CreatorId")
+ .HasColumnType("uuid");
+
+ b1.Property("FacebookUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("InstagramUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("LinkedInUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("RedditUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("TikTokUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("WebsiteUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("XUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("YoutubeUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.HasKey("CreatorId");
+
+ b1.ToTable("Socials", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("CreatorId");
+ });
+
+ b.Navigation("Colors")
+ .IsRequired();
+
+ b.Navigation("Images")
+ .IsRequired();
+
+ b.Navigation("Socials")
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20241201173048_AddThumbnailUrl.cs b/backend/src/Web/Features/Contents/Data/Migrations/20241201173048_AddThumbnailUrl.cs
new file mode 100644
index 0000000..0bef1fe
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/Migrations/20241201173048_AddThumbnailUrl.cs
@@ -0,0 +1,31 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Hutopy.Web.Features.Contents.Data.Migrations
+{
+ ///
+ public partial class AddThumbnailUrl : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ThumbnailUrl",
+ schema: "Content",
+ table: "Contents",
+ type: "character varying(512)",
+ maxLength: 512,
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ThumbnailUrl",
+ schema: "Content",
+ table: "Contents");
+ }
+ }
+}
diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20241201182352_AddPresentationInfos.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20241201182352_AddPresentationInfos.Designer.cs
new file mode 100644
index 0000000..6899120
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/Migrations/20241201182352_AddPresentationInfos.Designer.cs
@@ -0,0 +1,390 @@
+//
+using System;
+using Hutopy.Web.Features.Contents.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Hutopy.Web.Features.Contents.Data.Migrations
+{
+ [DbContext(typeof(ContentDbContext))]
+ [Migration("20241201182352_AddPresentationInfos")]
+ partial class AddPresentationInfos
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("Content")
+ .HasAnnotation("ProductVersion", "8.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("HtmlFileUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("ThumbnailUrl")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Urls")
+ .HasColumnType("text[]");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedBy");
+
+ b.ToTable("Contents", "Content");
+ });
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Title")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Creators", "Content");
+ });
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
+ {
+ b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator")
+ .WithMany()
+ .HasForeignKey("CreatedBy")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 =>
+ {
+ b1.Property("ContentId")
+ .HasColumnType("uuid");
+
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id"));
+
+ b1.Property("Reaction")
+ .HasColumnType("integer");
+
+ b1.Property("UserId")
+ .HasColumnType("uuid");
+
+ b1.Property("UserName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b1.HasKey("ContentId", "Id");
+
+ b1.ToTable("Reactions", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("ContentId");
+ });
+
+ b.Navigation("Creator");
+
+ b.Navigation("Reactions");
+ });
+
+ modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
+ {
+ b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 =>
+ {
+ b1.Property("CreatorId")
+ .HasColumnType("uuid");
+
+ b1.Property("Background")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Error")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnBackground")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnError")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnPrimary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnSecondary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("OnSurface")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Primary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Secondary")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.Property("Surface")
+ .IsRequired()
+ .HasMaxLength(9)
+ .HasColumnType("character varying(9)");
+
+ b1.HasKey("CreatorId");
+
+ b1.ToTable("Colors", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("CreatorId");
+ });
+
+ b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 =>
+ {
+ b1.Property("CreatorId")
+ .HasColumnType("uuid");
+
+ b1.Property("Banner")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("Logo")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.HasKey("CreatorId");
+
+ b1.ToTable("Images", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("CreatorId");
+ });
+
+ b.OwnsOne("Hutopy.Web.Features.Contents.Data.PresentationInfos", "PresentationInfos", b1 =>
+ {
+ b1.Property("CreatorId")
+ .HasColumnType("uuid");
+
+ b1.Property("Email")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("Image1Url")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("Image2Url")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("Image3Url")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("Image4Url")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("ImagesSubtitle")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("ImagesText")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("MainImageText")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("MainImageUrl")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("MainVideoText")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("PhoneNumber")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("Title")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("VideoSubtitle")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("VideoSubtitleMain")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("VideoText")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("VideoUrl")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("VideoUrlMain")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.HasKey("CreatorId");
+
+ b1.ToTable("PresentationInfos", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("CreatorId");
+ });
+
+ b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 =>
+ {
+ b1.Property("CreatorId")
+ .HasColumnType("uuid");
+
+ b1.Property("FacebookUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("InstagramUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("LinkedInUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("RedditUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("TikTokUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("WebsiteUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("XUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.Property("YoutubeUrl")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b1.HasKey("CreatorId");
+
+ b1.ToTable("Socials", "Content");
+
+ b1.WithOwner()
+ .HasForeignKey("CreatorId");
+ });
+
+ b.Navigation("Colors")
+ .IsRequired();
+
+ b.Navigation("Images")
+ .IsRequired();
+
+ b.Navigation("PresentationInfos")
+ .IsRequired();
+
+ b.Navigation("Socials")
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20241201182352_AddPresentationInfos.cs b/backend/src/Web/Features/Contents/Data/Migrations/20241201182352_AddPresentationInfos.cs
new file mode 100644
index 0000000..be125e8
--- /dev/null
+++ b/backend/src/Web/Features/Contents/Data/Migrations/20241201182352_AddPresentationInfos.cs
@@ -0,0 +1,59 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Hutopy.Web.Features.Contents.Data.Migrations
+{
+ ///
+ public partial class AddPresentationInfos : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "PresentationInfos",
+ schema: "Content",
+ columns: table => new
+ {
+ CreatorId = table.Column(type: "uuid", nullable: false),
+ PhoneNumber = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ Email = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ Title = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ MainImageUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ MainImageText = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ MainVideoText = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ ImagesSubtitle = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ Image1Url = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ Image2Url = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ Image3Url = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ Image4Url = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ ImagesText = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ VideoSubtitle = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ VideoSubtitleMain = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ VideoUrlMain = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ VideoUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ VideoText = table.Column