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(type: "character varying(255)", maxLength: 255, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PresentationInfos", x => x.CreatorId); + table.ForeignKey( + name: "FK_PresentationInfos_Creators_CreatorId", + column: x => x.CreatorId, + principalSchema: "Content", + principalTable: "Creators", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PresentationInfos", + schema: "Content"); + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20241202131957_LongerStringPresentationInfos.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20241202131957_LongerStringPresentationInfos.Designer.cs new file mode 100644 index 0000000..59ca1d8 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20241202131957_LongerStringPresentationInfos.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("20241202131957_LongerStringPresentationInfos")] + partial class LongerStringPresentationInfos + { + /// + 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(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image2Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image3Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image4Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("MainVideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("Title") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitleMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("VideoUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoUrlMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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/20241202131957_LongerStringPresentationInfos.cs b/backend/src/Web/Features/Contents/Data/Migrations/20241202131957_LongerStringPresentationInfos.cs new file mode 100644 index 0000000..1d25967 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20241202131957_LongerStringPresentationInfos.cs @@ -0,0 +1,348 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + /// + public partial class LongerStringPresentationInfos : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "VideoUrlMain", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "VideoUrl", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "VideoText", + schema: "Content", + table: "PresentationInfos", + type: "character varying(10000)", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "VideoSubtitleMain", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "VideoSubtitle", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "Title", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "MainVideoText", + schema: "Content", + table: "PresentationInfos", + type: "character varying(10000)", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "MainImageUrl", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "MainImageText", + schema: "Content", + table: "PresentationInfos", + type: "character varying(10000)", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "ImagesText", + schema: "Content", + table: "PresentationInfos", + type: "character varying(10000)", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "ImagesSubtitle", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "Image4Url", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "Image3Url", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "Image2Url", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "Image1Url", + schema: "Content", + table: "PresentationInfos", + type: "character varying(2000)", + maxLength: 2000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "VideoUrlMain", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + + migrationBuilder.AlterColumn( + name: "VideoUrl", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + + migrationBuilder.AlterColumn( + name: "VideoText", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(10000)", + oldMaxLength: 10000); + + migrationBuilder.AlterColumn( + name: "VideoSubtitleMain", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + + migrationBuilder.AlterColumn( + name: "VideoSubtitle", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + + migrationBuilder.AlterColumn( + name: "Title", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + + migrationBuilder.AlterColumn( + name: "MainVideoText", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(10000)", + oldMaxLength: 10000); + + migrationBuilder.AlterColumn( + name: "MainImageUrl", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + + migrationBuilder.AlterColumn( + name: "MainImageText", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(10000)", + oldMaxLength: 10000); + + migrationBuilder.AlterColumn( + name: "ImagesText", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(10000)", + oldMaxLength: 10000); + + migrationBuilder.AlterColumn( + name: "ImagesSubtitle", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + + migrationBuilder.AlterColumn( + name: "Image4Url", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + + migrationBuilder.AlterColumn( + name: "Image3Url", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + + migrationBuilder.AlterColumn( + name: "Image2Url", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + + migrationBuilder.AlterColumn( + name: "Image1Url", + schema: "Content", + table: "PresentationInfos", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2000)", + oldMaxLength: 2000); + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.Designer.cs new file mode 100644 index 0000000..dcd72ef --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.Designer.cs @@ -0,0 +1,400 @@ +// +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("20250108022601_AddComputedColumnAndIndex_CreatorName")] + partial class AddComputedColumnAndIndex_CreatorName + { + /// + 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("NormalizedName") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + 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(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image2Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image3Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image4Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("MainVideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("Title") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitleMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("VideoUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoUrlMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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/20250108022601_AddComputedColumnAndIndex_CreatorName.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.cs new file mode 100644 index 0000000..ab8bd48 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250108022601_AddComputedColumnAndIndex_CreatorName.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + /// + public partial class AddComputedColumnAndIndex_CreatorName : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NormalizedName", + schema: "Content", + table: "Creators", + type: "character varying(255)", + maxLength: 255, + nullable: false, + computedColumnSql: "LOWER( \"Content\".\"Creators\".\"Name\")", + stored: true); + + migrationBuilder.CreateIndex( + name: "IX_Creators_NormalizedName", + schema: "Content", + table: "Creators", + column: "NormalizedName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Creators_NormalizedName", + schema: "Content", + table: "Creators"); + + migrationBuilder.DropColumn( + name: "NormalizedName", + schema: "Content", + table: "Creators"); + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20250108210552_Add_Verified_Creator.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250108210552_Add_Verified_Creator.Designer.cs new file mode 100644 index 0000000..fbc37b9 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250108210552_Add_Verified_Creator.Designer.cs @@ -0,0 +1,403 @@ +// +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("20250108210552_Add_Verified_Creator")] + partial class Add_Verified_Creator + { + /// + 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("NormalizedName") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Verified") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + 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(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image2Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image3Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image4Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("MainVideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("Title") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitleMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("VideoUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoUrlMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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/20250108210552_Add_Verified_Creator.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250108210552_Add_Verified_Creator.cs new file mode 100644 index 0000000..487b307 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250108210552_Add_Verified_Creator.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + /// + public partial class Add_Verified_Creator : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Verified", + schema: "Content", + table: "Creators", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Verified", + schema: "Content", + table: "Creators"); + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/20250109015556_Adds_AcceptDonation_Creator.Designer.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250109015556_Adds_AcceptDonation_Creator.Designer.cs new file mode 100644 index 0000000..1a12506 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250109015556_Adds_AcceptDonation_Creator.Designer.cs @@ -0,0 +1,406 @@ +// +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("20250109015556_Adds_AcceptDonation_Creator")] + partial class Adds_AcceptDonation_Creator + { + /// + 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("AcceptDonation") + .HasColumnType("boolean"); + + 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("NormalizedName") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Verified") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + 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(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image2Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image3Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image4Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("MainVideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("Title") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitleMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("VideoUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoUrlMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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/20250109015556_Adds_AcceptDonation_Creator.cs b/backend/src/Web/Features/Contents/Data/Migrations/20250109015556_Adds_AcceptDonation_Creator.cs new file mode 100644 index 0000000..339404e --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/20250109015556_Adds_AcceptDonation_Creator.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + /// + public partial class Adds_AcceptDonation_Creator : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AcceptDonation", + schema: "Content", + table: "Creators", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AcceptDonation", + schema: "Content", + table: "Creators"); + } + } +} diff --git a/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs b/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs new file mode 100644 index 0000000..1beecf0 --- /dev/null +++ b/backend/src/Web/Features/Contents/Data/Migrations/ContentDbContextModelSnapshot.cs @@ -0,0 +1,403 @@ +// +using System; +using Hutopy.Web.Features.Contents.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Data.Migrations +{ + [DbContext(typeof(ContentDbContext))] + partial class ContentDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("AcceptDonation") + .HasColumnType("boolean"); + + 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("NormalizedName") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasComputedColumnSql("LOWER( \"Content\".\"Creators\".\"Name\")", true); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Verified") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + 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(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image2Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image3Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("Image4Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("ImagesText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("MainImageUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("MainVideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("Title") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitle") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoSubtitleMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoText") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b1.Property("VideoUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b1.Property("VideoUrlMain") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + 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/DependencyInjection.cs b/backend/src/Web/Features/Contents/DependencyInjection.cs new file mode 100644 index 0000000..c92c791 --- /dev/null +++ b/backend/src/Web/Features/Contents/DependencyInjection.cs @@ -0,0 +1,16 @@ +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddContentModule( + this WebApplicationBuilder builder, + Action? configureAction = null) + { + builder.Services.AddDbContext(configureAction); + builder.Services.AddScoped(); + + return builder; + } +} diff --git a/backend/src/Web/Features/Contents/EventHandlers/StripeAccountConfiguredHandler.cs b/backend/src/Web/Features/Contents/EventHandlers/StripeAccountConfiguredHandler.cs new file mode 100644 index 0000000..af735c7 --- /dev/null +++ b/backend/src/Web/Features/Contents/EventHandlers/StripeAccountConfiguredHandler.cs @@ -0,0 +1,43 @@ +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Memberships.Events; + +namespace Hutopy.Web.Features.Contents.EventHandlers; + +[UsedImplicitly] +public class StripeAccountConfiguredHandler( + ILogger logger, + IServiceScopeFactory scopeFactory) + : IEventHandler +{ + public async Task HandleAsync( + StripeAccountConfigured eventModel, + CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + + var creator = await dbContext.FindAsync( + [eventModel.CreatorId], + cancellationToken: ct); + + if (creator is null) + { + logger.LogError( + "Creator with id {CreatorId} was not found.", + eventModel.CreatorId); + return; + } + + creator.AcceptDonation = true; + + var rows = await dbContext.SaveChangesAsync(ct); + + if (rows is 0 or > 1) + { + logger.LogError( + "An error occured while updating Creator with id {CreatorId}: rows:{Rows}", + eventModel.CreatorId, + rows); + } + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/AddReaction.cs b/backend/src/Web/Features/Contents/Handlers/AddReaction.cs new file mode 100644 index 0000000..301f200 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/AddReaction.cs @@ -0,0 +1,83 @@ +using Hutopy.Web.Extensions; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Contents.Data.Enums; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public sealed class AddReactionRequest +{ + public required Guid ContentId { get; set; } + public required string Reaction { get; set; } + public required Guid UserId { get; set; } + public required string UserName { get; set; } +} + +[PublicAPI] +internal sealed class AddReactionRequestValidator + : Validator +{ + public AddReactionRequestValidator() + { + RuleFor(r => r.Reaction) + .NotNull() + .Must(BeAValidReaction) + .WithMessage("'{PropertyValue}' is not a valid reaction."); + } + + private bool BeAValidReaction(string reaction) + { + return Enum.TryParse(typeof(Reaction), reaction, true, out _); + } +} + +[PublicAPI] +public class AddReaction( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/content/reaction"); + Options(o => o.WithTags("Contents")); + } + + public override async Task HandleAsync( + AddReactionRequest req, + CancellationToken ct) + { + var content = await context.Contents.SingleAsync(x => x.Id == req.ContentId, ct); + var reactionEnum = req.Reaction.ToEnum(); + var currentReaction = content.Reactions.SingleOrDefault(x => x.UserId == req.UserId); + + // Already reacted or reaction didn't change, do nothing + if (currentReaction != null && currentReaction.Reaction == reactionEnum) + { + return; + } + + // User has already reacted, remove the existing reaction + if (currentReaction != null) + { + content.Reactions.Remove(currentReaction); + } + + // If the new reaction is valid, add or update the reaction + if (reactionEnum.HasValue) + { + var reaction = new ContentReaction + { + Reaction = reactionEnum.Value, + UserId = req.UserId, + UserName = req.UserName + }; + + content.Reactions.Add(reaction); + } + + await context.SaveChangesAsync(ct); + } +} + + + diff --git a/backend/src/Web/Features/Contents/Handlers/ChangeBanner.cs b/backend/src/Web/Features/Contents/Handlers/ChangeBanner.cs new file mode 100644 index 0000000..04b8cb4 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/ChangeBanner.cs @@ -0,0 +1,60 @@ +using Hutopy.Web.Common.BlobStorage; +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record ChangeBannerRequest( + Guid CreatorId, + IFormFile File); + +[PublicAPI] +public record ChangeBannerResponse( + string BlobUrl); + +[PublicAPI] +public class ChangeBannerHandler( + ContentDbContext context, + AzureBlobStorage blobStorage) + : Endpoint +{ + public override void Configure() + { + Post("/api/creators/{CreatorId}/banner"); + Options(o => o.WithTags("Creators")); + AllowFileUploads(); + } + + public override async Task HandleAsync( + ChangeBannerRequest request, + CancellationToken ct) + { + var creator = await context + .Creators + .Include(c => c.Images) + .SingleOrDefaultAsync( + c => c.Id == request.CreatorId, + cancellationToken: ct); + + if (creator is null) + { + await SendNotFoundAsync(ct); + return; + } + + var blobUrl = await blobStorage.UploadFileAsync( + ContainerNames.Creators, + $"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.BannerPicture}", + request.File.OpenReadStream(), + request.File.ContentType, + ct); + + creator.Images.Banner = blobUrl; + + await context.SaveChangesAsync(ct); + + await SendOkAsync( + new ChangeBannerResponse(blobUrl), + ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/ChangeColors.cs b/backend/src/Web/Features/Contents/Handlers/ChangeColors.cs new file mode 100644 index 0000000..0aa63d3 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/ChangeColors.cs @@ -0,0 +1,113 @@ +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record ChangeColorsRequest( + Guid CreatorId, + string Primary, + string Secondary, + string Background, + string Surface, + string Error, + string OnPrimary, + string OnSecondary, + string OnBackground, + string OnSurface, + string OnError); + +[PublicAPI] +public sealed class ChangeColorsRequestValidator + : Validator +{ + public ChangeColorsRequestValidator() + { + RuleFor(x => x.Primary) + .MinimumLength(4).WithMessage("The minimum value should be in the format #444") + .MaximumLength(9).WithMessage("The maximum value should be in the format #11223344") + .Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #"); + + RuleFor(x => x.Secondary) + .MinimumLength(4).WithMessage("The minimum value should be in the format #444") + .MaximumLength(9).WithMessage("The maximum value should be in the format #11223344") + .Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #"); + + RuleFor(x => x.Background) + .MinimumLength(4).WithMessage("The minimum value should be in the format #444") + .MaximumLength(9).WithMessage("The maximum value should be in the format #11223344") + .Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #"); + + RuleFor(x => x.Surface) + .MinimumLength(4).WithMessage("The minimum value should be in the format #444") + .MaximumLength(9).WithMessage("The maximum value should be in the format #11223344") + .Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #"); + + RuleFor(x => x.Error) + .MinimumLength(4).WithMessage("The minimum value should be in the format #444") + .MaximumLength(9).WithMessage("The maximum value should be in the format #11223344") + .Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #"); + + RuleFor(x => x.OnPrimary) + .MinimumLength(4).WithMessage("The minimum value should be in the format #444") + .MaximumLength(9).WithMessage("The maximum value should be in the format #11223344") + .Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #"); + + RuleFor(x => x.OnSecondary) + .MinimumLength(4).WithMessage("The minimum value should be in the format #444") + .MaximumLength(9).WithMessage("The maximum value should be in the format #11223344") + .Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #"); + + RuleFor(x => x.OnBackground) + .MinimumLength(4).WithMessage("The minimum value should be in the format #444") + .MaximumLength(9).WithMessage("The maximum value should be in the format #11223344") + .Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #"); + + RuleFor(x => x.OnSurface) + .MinimumLength(4).WithMessage("The minimum value should be in the format #444") + .MaximumLength(9).WithMessage("The maximum value should be in the format #11223344") + .Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #"); + + RuleFor(x => x.OnError) + .MinimumLength(4).WithMessage("The minimum value should be in the format #444") + .MaximumLength(9).WithMessage("The maximum value should be in the format #11223344") + .Must(x => x.StartsWith('#')).WithMessage("The format should be a valid html color and start with #"); + } +} + +public class ChangeColorsHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/creators/{CreatorId}/colors"); + Options(o => o.WithTags("Creators")); + } + + public override async Task HandleAsync( + ChangeColorsRequest request, + CancellationToken ct) + { + var creator = await context + .Creators + .Include(c => c.Colors) + .SingleAsync( + c => c.Id == request.CreatorId, + cancellationToken: ct); + + creator.Colors.Primary = request.Primary; + creator.Colors.Secondary = request.Secondary; + creator.Colors.Background = request.Background; + creator.Colors.Surface = request.Surface; + creator.Colors.Error = request.Error; + creator.Colors.OnPrimary = request.OnPrimary; + creator.Colors.OnSecondary = request.OnSecondary; + creator.Colors.OnBackground = request.OnBackground; + creator.Colors.OnSurface = request.OnSurface; + creator.Colors.OnError = request.OnError; + + await context.SaveChangesAsync(ct); + + await SendOkAsync(ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/ChangeLogo.cs b/backend/src/Web/Features/Contents/Handlers/ChangeLogo.cs new file mode 100644 index 0000000..80deb04 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/ChangeLogo.cs @@ -0,0 +1,70 @@ +using Hutopy.Web.Common.BlobStorage; +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record ChangeLogoRequest( + Guid CreatorId, + IFormFile File); + +[PublicAPI] +public sealed class ChangeLogoRequestValidator : Validator +{ + public ChangeLogoRequestValidator() + { + RuleFor(x => x.CreatorId) + .NotNull() + .NotEmpty(); + + RuleFor(x => x.File) + .NotNull() + .NotEmpty(); + } +} + +[PublicAPI] +public class ChangeLogoHandler( + ContentDbContext context, + AzureBlobStorage blobStorage) + : Endpoint +{ + public override void Configure() + { + Post("/api/creators/{CreatorId}/logo"); + Options(o => o.WithTags("Creators")); + AllowFileUploads(); + } + + public override async Task HandleAsync( + ChangeLogoRequest request, + CancellationToken ct) + { + var creator = await context + .Creators + .Include(c => c.Images) + .SingleOrDefaultAsync( + c => c.Id == request.CreatorId, + cancellationToken: ct); + + if (creator is null) + { + await SendNotFoundAsync(ct); + return; + } + + // TODO: this upload should be done to the Creators container + var blobUrl = await blobStorage.UploadFileAsync( + ContainerNames.Creators, + $"{request.CreatorId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}", + request.File.OpenReadStream(), + request.File.ContentType, + ct); + + creator.Images.Logo = blobUrl; + + await context.SaveChangesAsync(ct); + + await SendOkAsync(blobUrl, ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs b/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs new file mode 100644 index 0000000..8573bf7 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs @@ -0,0 +1,115 @@ +using Hutopy.Web.Common.BlobStorage; +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record ChangePresentationInfosRequest( + Guid CreatorId, + string? PhoneNumber, + string? Email, + string? Title, + string? MainImageText, + string? MainVideoText, + string? ImagesSubtitle, + string? ImagesText, + string? VideoSubtitle, + string? VideoSubtitleMain, + string? VideoUrlMain, + string? VideoUrl, + string? VideoText, + string? MainImageUrl, + string? Image1Url, + string? Image2Url, + string? Image3Url, + string? Image4Url, + IFormFile? MainImage, + IFormFile? Image1, + IFormFile? Image2, + IFormFile? Image3, + IFormFile? Image4); + +[PublicAPI] +public class ChangePresentationInfosHandler( + ContentDbContext context, + AzureBlobStorage blobStorage) + : Endpoint +{ + public override void Configure() + { + Post("/api/creators/{CreatorId}/presentation-infos"); + Options(o => o.WithTags("Creators")); + AllowFileUploads(); + } + + public override async Task HandleAsync( + ChangePresentationInfosRequest request, + CancellationToken ct) + { + var creator = await context + .Creators + .Include(c => c.PresentationInfos) + .SingleOrDefaultAsync( + c => c.Id == request.CreatorId, + cancellationToken: ct); + + if (creator is null) + { + await SendNotFoundAsync(ct); + return; + } + + async Task UploadFileOrDefaultAsync( + IFormFile? file, + string subDirectory, + string fileName, + string? newUrl) + { + if (newUrl == "") + return ""; + + if (file != null) + { + return await blobStorage.UploadFileAsync( + ContainerNames.Creators, + $"{request.CreatorId}/{subDirectory}/{fileName}", + file.OpenReadStream(), + file.ContentType, + ct); + } + + return newUrl?.Trim() ?? ""; + } + + creator.PresentationInfos.MainImageUrl = await UploadFileOrDefaultAsync( + request.MainImage, "Profile", "MainImage", request.MainImageUrl); + + creator.PresentationInfos.Image1Url = await UploadFileOrDefaultAsync( + request.Image1, "Profile", "Image1", request.Image1Url); + + creator.PresentationInfos.Image2Url = await UploadFileOrDefaultAsync( + request.Image2, "Profile", "Image2", request.Image2Url); + + creator.PresentationInfos.Image3Url = await UploadFileOrDefaultAsync( + request.Image3, "Profile", "Image3", request.Image3Url); + + creator.PresentationInfos.Image4Url = await UploadFileOrDefaultAsync( + request.Image4, "Profile", "Image4", request.Image4Url); + + creator.PresentationInfos.PhoneNumber = request.PhoneNumber?.Trim() ?? ""; + creator.PresentationInfos.Email = request.Email?.Trim() ?? ""; + creator.PresentationInfos.Title = request.Title?.Trim() ?? ""; + creator.PresentationInfos.MainImageText = request.MainImageText?.Trim() ?? ""; + creator.PresentationInfos.MainVideoText = request.MainVideoText?.Trim() ?? ""; + creator.PresentationInfos.ImagesSubtitle = request.ImagesSubtitle?.Trim() ?? ""; + creator.PresentationInfos.ImagesText = request.ImagesText?.Trim() ?? ""; + creator.PresentationInfos.VideoSubtitle = request.VideoSubtitle?.Trim() ?? ""; + creator.PresentationInfos.VideoSubtitleMain = request.VideoSubtitleMain?.Trim() ?? ""; + creator.PresentationInfos.VideoUrlMain = request.VideoUrlMain?.Trim() ?? ""; + creator.PresentationInfos.VideoUrl = request.VideoUrl?.Trim() ?? ""; + creator.PresentationInfos.VideoText = request.VideoText?.Trim() ?? ""; + + await context.SaveChangesAsync(ct); + await SendOkAsync(ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/ChangeSocials.cs b/backend/src/Web/Features/Contents/Handlers/ChangeSocials.cs new file mode 100644 index 0000000..5b86c3e --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/ChangeSocials.cs @@ -0,0 +1,50 @@ +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record ChangeSocialsRequest( + Guid CreatorId, + string? FacebookUrl, + string? InstagramUrl, + string? XUrl, + string? LinkedInUrl, + string? TikTokUrl, + string? YoutubeUrl, + string? RedditUrl, + string? WebsiteUrl); + +[PublicAPI] +public class ChangeSocialsHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/creators/{CreatorId}/socials"); + Options(o => o.WithTags("Creators")); + } + + public override async Task HandleAsync(ChangeSocialsRequest request, CancellationToken ct) + { + var creator = await context + .Creators + .Include(c => c.Socials) + .SingleAsync( + c => c.Id == request.CreatorId, + cancellationToken: ct); + + creator.Socials.FacebookUrl = request.FacebookUrl; + creator.Socials.InstagramUrl = request.InstagramUrl; + creator.Socials.XUrl = request.XUrl; + creator.Socials.LinkedInUrl = request.LinkedInUrl; + creator.Socials.TikTokUrl = request.TikTokUrl; + creator.Socials.YoutubeUrl = request.YoutubeUrl; + creator.Socials.RedditUrl = request.RedditUrl; + creator.Socials.WebsiteUrl = request.WebsiteUrl; + + await context.SaveChangesAsync(ct); + + await SendOkAsync(ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/ChangeTitle.cs b/backend/src/Web/Features/Contents/Handlers/ChangeTitle.cs new file mode 100644 index 0000000..16a357a --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/ChangeTitle.cs @@ -0,0 +1,37 @@ +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record ChangeTitleRequest( + Guid CreatorId, + string? Title); + +[PublicAPI] +public class ChangeTitleHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/creators/{CreatorId}/title"); + Options(o => o.WithTags("Creators")); + } + + public override async Task HandleAsync( + ChangeTitleRequest request, + CancellationToken ct) + { + var creator = await context + .Creators + .SingleAsync( + c => c.Id == request.CreatorId, + cancellationToken: ct); + + creator.Title = request.Title; + + await context.SaveChangesAsync(ct); + + await SendOkAsync(ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/CreateContent.cs b/backend/src/Web/Features/Contents/Handlers/CreateContent.cs new file mode 100644 index 0000000..4d1cad1 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/CreateContent.cs @@ -0,0 +1,156 @@ +using System.Collections.Concurrent; +using Hutopy.Web.Common.BlobStorage; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record PostContentRequest( + Guid Id, + Guid CreatorId, + string Title, + string Description, + IFormFileCollection? Files, + IFormFile? Thumbnail, + string[]? ExternalUrls); + +[PublicAPI] +public sealed class PostContentRequestValidator : Validator +{ + public PostContentRequestValidator() + { + RuleFor(r => r.Id) + .NotNull().WithMessage("You should specify the Id") + .NotEmpty().WithMessage("You should specify a valid/not empty Id"); + + RuleFor(r => r.CreatorId) + .NotNull().WithMessage("You should specify the CreatorId") + .NotEmpty().WithMessage("You should specify a valid/not empty CreatorId"); + + RuleFor(r => r.Title) + .NotNull().WithMessage("You should specify the Title") + .NotEmpty().WithMessage("You should specify a valid/not empty Title"); + + RuleFor(r => r.Description) + .NotNull().WithMessage("You should specify the Description") + .NotEmpty().WithMessage("You should specify a valid/not empty Description"); + + RuleForEach(r => r.ExternalUrls) + .Must(url => Uri.IsWellFormedUriString(url, UriKind.Absolute) && + (url.StartsWith("http://") || url.StartsWith("https://"))) + .WithMessage("External URL must be a valid HTTP/HTTPS URL"); + + RuleFor(r => r.Thumbnail) + .Must(file => file == null || file.ContentType.StartsWith("image/")) + .WithMessage("Thumbnail must be an image"); + + + } +} + +public sealed class PostContent( + AzureBlobStorage blobStorage, + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/contents"); + Options(o => o.WithTags("Contents")); + AllowFileUploads(); + } + + public override async Task HandleAsync(PostContentRequest req, CancellationToken ct) + { + var urls = new ConcurrentBag(); + string? thumbnailUrl = null; + + await using var transaction = await context.Database.BeginTransactionAsync(ct); + + try + { + + if (req.Files is not null) + { + await Parallel.ForEachAsync(req.Files, ct, async (file, ict) => + { + try + { + var contentUrl = await SaveFileAsync(req.CreatorId, req.Id, file, ict); + urls.Add(contentUrl); + } + catch (Exception ex) + { + Logger.LogError("Failed to upload file {FileName}: {Message}", file.FileName, ex.Message); + } + }); + } + + + if (req.ExternalUrls is not null) + { + foreach (var externalUrl in req.ExternalUrls.Where(url => !string.IsNullOrWhiteSpace(url))) + { + urls.Add(externalUrl); + } + } + + + if (req.Thumbnail is not null) + { + try + { + thumbnailUrl = await SaveFileAsync(req.CreatorId, req.Id, req.Thumbnail, ct, isThumbnail: true); + } + catch (Exception ex) + { + Logger.LogError("Error uploading thumbnail: {Message}", ex.Message); + } + } + + + await context.Contents.AddAsync(new Content + { + Id = req.Id, + CreatedBy = User.GetUserId(), + Title = req.Title, + Description = req.Description, + Urls = urls.IsEmpty ? null : urls.ToArray(), + ThumbnailUrl = thumbnailUrl, + }, ct); + + await context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + await SendOkAsync(new { Message = "Content published successfully!" }, ct); + } + catch (Exception ex) + { + await transaction.RollbackAsync(ct); + Logger.LogError("Transaction failed: {Message}", ex.Message); + throw; + } + } + + private async Task SaveFileAsync( + Guid creatorId, + Guid contentId, + IFormFile file, + CancellationToken ct = default, + bool isThumbnail = false) + { + + var blobName = isThumbnail + ? $"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/thumbnail-{file.FileName}" + : $"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/{file.FileName}"; + + + return await blobStorage.UploadFileAsync( + ContainerNames.Creators, + blobName, + file.OpenReadStream(), + file.ContentType, + ct: ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs b/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs new file mode 100644 index 0000000..f07ccc2 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs @@ -0,0 +1,110 @@ +using System.Text; +using Hutopy.Web.Common.BlobStorage; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Contents.Handlers.Models; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record PostContentFromHtmlRequest( + Guid Id, + Guid CreatorId, + string Title, + string HtmlContent +); + +[PublicAPI] +public sealed class PostContentFromHtmlRequestValidator : Validator +{ + public PostContentFromHtmlRequestValidator() + { + RuleFor(r => r.Id) + .NotNull().WithMessage("You should specify the Id") + .NotEmpty().WithMessage("You should specify a valid/not empty Id"); + + RuleFor(r => r.CreatorId) + .NotNull().WithMessage("You should specify the CreatorId") + .NotEmpty().WithMessage("You should specify a valid/not empty CreatorId"); + + RuleFor(r => r.Title) + .NotNull().WithMessage("You should specify the Title") + .NotEmpty().WithMessage("You should specify a valid/not empty Title"); + } +} + +public sealed class PostContentHtml( + AzureBlobStorage blobStorage, + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/contents/html"); + Options(o => o.WithTags("Contents")); + AllowFileUploads(); + } + + public override async Task HandleAsync( + PostContentFromHtmlRequest req, + CancellationToken ct) + { + var htmlFileUrl = await SaveHtmlContentAsHtmlFileAsync( + req.CreatorId, + req.Id, + req.HtmlContent, + ct); + + await context.Contents.AddAsync( + new Content { Id = req.Id, CreatedBy = User.GetUserId(), Title = req.Title, HtmlFileUrl = htmlFileUrl }, + ct); + + await context.SaveChangesAsync(ct); + + var content = await context + .Contents + .Select(c => new ContentModel + { + Id = c.Id, + CreatedBy = c.CreatedBy, + CreatedByName = c.Creator!.Name, + CreatedByPortraitUrl = c.Creator.Images.Logo, + CreatedAt = c.CreatedAt, + DeletedBy = c.DeletedBy, + DeletedAt = c.DeletedAt, + Title = c.Title, + Description = c.Description, + Urls = c.Urls, + ThumbnailUrl = c.ThumbnailUrl, + HtmlFileUrl = htmlFileUrl + }) + .SingleOrDefaultAsync( + c => c.Id == req.Id, + cancellationToken: ct); + + await SendOkAsync(content, ct); + } + + private async Task SaveHtmlContentAsHtmlFileAsync( + Guid creatorId, + Guid contentId, + string htmlContent, + CancellationToken ct = default) + { + var fileName = $"{contentId}.html"; + var filePath = $"{creatorId}/{SubDirectoryNames.Contents}/{fileName}"; + + // Convert the HTML string into a stream + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(htmlContent)); + + // Upload the stream as an HTML file + var url = await blobStorage.UploadFileAsync( + ContainerNames.Creators, + filePath, + stream, + "text/html", + ct: ct); + + return url; + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs b/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs new file mode 100644 index 0000000..d52e4fc --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/CreateCreator.cs @@ -0,0 +1,91 @@ +using System.Net; +using FluentValidation.Results; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Contents.Data; +using Npgsql; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record CreateCreatorRequest( + Guid CreatorId, + string Name); + +[UsedImplicitly] +public sealed class CreateCreatorRequestValidator : Validator +{ + public CreateCreatorRequestValidator() + { + RuleFor(r => r.CreatorId) + .NotNull().WithMessage("You should specify the CreatorId") + .NotEmpty().WithMessage("You should specify a valid/not empty CreatorId"); + + RuleFor(r => r.Name) + .NotNull().WithMessage("You should specify the Name") + .NotEmpty().WithMessage("You should specify a valid/not empty Name"); + } +} + +[PublicAPI] +public sealed class CreateCreatorHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/creators"); + Options(o => o.WithTags("Creators")); + } + + public override async Task HandleAsync( + CreateCreatorRequest req, + CancellationToken ct) + { + try + { + await context.Creators.AddAsync( + new Creator + { + Id = req.CreatorId, + CreatedBy = User.GetUserId(), + Name = req.Name, + Colors = + { + Primary = "#6200EE", + OnPrimary = "#FFFFFF", + Secondary = "#03DAC6", + OnSecondary = "#000000", + Surface = "#FFFFFF", + OnSurface = "#000000", + Error = "#B00020", + OnError = "#FFFFFF", + Background = "#FFFFFF", + OnBackground = "#000000", + } + }, + ct); + + await context.SaveChangesAsync(ct); + + await SendOkAsync(ct); + } + catch (Exception e) + { + if (e.InnerException is PostgresException innerException) + { + if (innerException.ConstraintName == "IX_Creators_NormalizedName") + { + await SendResultAsync(new ProblemDetails( + [new ValidationFailure(nameof(Creator.Name), "The name is already taken.")], + (int)HttpStatusCode.Conflict)); + } + } + else + { + await SendResultAsync(new ProblemDetails( + [new ValidationFailure(nameof(Creator.Name), e.Message)], + (int)HttpStatusCode.Conflict)); + } + } + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/DeleteContent.cs b/backend/src/Web/Features/Contents/Handlers/DeleteContent.cs new file mode 100644 index 0000000..26e37e2 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/DeleteContent.cs @@ -0,0 +1,60 @@ +using Hutopy.Web.Common; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record DeleteContentRequest( + Guid ContentId); + +[PublicAPI] +public sealed class DeleteContentRequestValidator : Validator +{ + public DeleteContentRequestValidator() + { + RuleFor(r => r.ContentId) + .NotNull().WithMessage("You should specify the ContentId") + .NotEmpty().WithMessage("You should specify a valid/not empty ContentId"); + } +} + +public sealed class DeleteContent( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Delete("/api/contents/{ContentId}"); + Options(o => o.WithTags("Contents")); + } + + public override async Task HandleAsync( + DeleteContentRequest req, + CancellationToken ct) + { + var content = await context.Contents.FindAsync( + [req.ContentId], + ct); + + if (content is null) + { + await SendNotFoundAsync(ct); + return; + } + + var userId = HttpContext.User.GetUserId(); + if (content.CreatedBy != userId) + { + await SendForbiddenAsync(ct); + return; + } + + content.DeletedAt = DateTimeOffset.UtcNow; + content.DeletedBy = userId; + + await context.SaveChangesAsync(ct); + + await SendOkAsync(ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/GetContent.cs b/backend/src/Web/Features/Contents/Handlers/GetContent.cs new file mode 100644 index 0000000..7ff4ea7 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/GetContent.cs @@ -0,0 +1,59 @@ +using Hutopy.Web.Extensions; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Contents.Handlers.Models; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public sealed class GetContentRequest +{ + public Guid ContentId { get; set; } +} + +[PublicAPI] +public class GetContent( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Get("/api/contents/{ContentId:guid}"); + Options(o => o.WithTags("Contents")); + AllowAnonymous(); + } + + public override async Task HandleAsync( + GetContentRequest req, + CancellationToken ct) + { + var content = await context + .Contents + .Select(c => new ContentModel + { + Id = c.Id, + CreatedBy = c.CreatedBy, + CreatedByName = c.Creator!.Name, + CreatedByPortraitUrl = c.Creator.Images.Logo, + CreatedAt = c.CreatedAt, + DeletedBy = c.DeletedBy, + DeletedAt = c.DeletedAt, + Title = c.Title, + Description = c.Description, + Urls = c.Urls, + ThumbnailUrl = c.ThumbnailUrl, + HtmlFileUrl = c.HtmlFileUrl ?? "", + Reactions = c.Reactions.Select(x => new ReactionModel + { + Reaction = x.Reaction.FromEnum(), UserId = x.UserId, UserName = x.UserName + }).ToList() + }) + .SingleOrDefaultAsync( + c => c.Id == req.ContentId, + cancellationToken: ct); + + if (content is null) + await SendNotFoundAsync(cancellation: ct); + else + await SendAsync(content, cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs b/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs new file mode 100644 index 0000000..e502e8e --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs @@ -0,0 +1,68 @@ +using Hutopy.Web.Extensions; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Contents.Handlers.Models; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public sealed class GetContentsByCreatorRequest +{ + public Guid CreatorId { get; set; } + [BindFrom("page_size")] public int PageSize { get; set; } = 10; + [BindFrom("last_id")] public Guid? LastId { get; set; } +} + +[PublicAPI] +public class GetContentsByCreatorHandler( + ContentDbContext context) + : Endpoint> +{ + public override void Configure() + { + Get("/api/contents/creator/{CreatorId:guid}"); + Options(o => o.WithTags("Contents")); + AllowAnonymous(); + } + + public override async Task HandleAsync( + GetContentsByCreatorRequest req, + CancellationToken ct) + { + var query = context.Contents + .Where(c => c.CreatedBy == req.CreatorId && c.DeletedAt == null) + .OrderByDescending(c => c.CreatedAt); + + if (req.LastId.HasValue) + { + query = query.Where(c => c.Id > req.LastId.Value) + .OrderByDescending(c => c.CreatedAt); + } + + var content = await query + .Select(c => new ContentModel + { + Id = c.Id, + CreatedBy = c.CreatedBy, + CreatedByName = c.Creator!.Name, + CreatedByPortraitUrl = c.Creator.Images.Logo, + CreatedAt = c.CreatedAt, + DeletedBy = c.DeletedBy, + DeletedAt = c.DeletedAt, + Title = c.Title, + Description = c.Description, + Urls = c.Urls, + ThumbnailUrl = c.ThumbnailUrl, + HtmlFileUrl = c.HtmlFileUrl ?? "", + Reactions = c.Reactions.Select(x => new ReactionModel + { + Reaction = x.Reaction.FromEnum(), + UserId = x.UserId, + UserName = x.UserName + }).ToList() + }) + .Take(req.PageSize) + .ToListAsync(ct); + + await SendAsync(content, cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs b/backend/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs new file mode 100644 index 0000000..75e0dbc --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/GetCreatorByAlias.cs @@ -0,0 +1,83 @@ +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Contents.Handlers.Models; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public sealed class GetCreatorByAliasRequest +{ + public required string Name { get; set; } +} + +[PublicAPI] +public record struct GetCreatorByAliasResponse( + Guid Id, + Guid CreatedBy, + DateTimeOffset CreatedAt, + bool Verified, + bool AcceptDonation, + string Name, + string? Title, + Socials Socials, + Colors Colors, + PresentationInfos PresentationInfos, + Images Images); + +[UsedImplicitly] +public sealed class GetCreatorByAliasRequestValidator + : Validator +{ + public GetCreatorByAliasRequestValidator() + { + RuleFor(r => r.Name) + .NotNull().WithMessage("You should specify the Name") + .NotEmpty().WithMessage("You should specify a valid/not empty Name"); + } +} + +[PublicAPI] +public class GetCreatorByAliasHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Get("/api/creators/@{Name}"); + Options((o => o.WithTags("Creators"))); + AllowAnonymous(); + } + + public override async Task HandleAsync( + GetCreatorByAliasRequest req, + CancellationToken ct) + { + var creatorName = req.Name.ToLower(); + + var creator = await context + .Creators + .Where(c => EF.Functions.ILike(c.Name, creatorName)) + .FirstOrDefaultAsync(ct); + + if (creator is null) + { + await SendNotFoundAsync(ct); + } + else + { + var model = new GetCreatorByAliasResponse( + creator.Id, + creator.CreatedBy, + creator.CreatedAt, + creator.Verified, + creator.AcceptDonation, + creator.Name, + creator.Title, + creator.Socials, + creator.Colors, + creator.PresentationInfos, + creator.Images); + + await SendAsync(model, cancellation: ct); + } + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/GetCreatorById.cs b/backend/src/Web/Features/Contents/Handlers/GetCreatorById.cs new file mode 100644 index 0000000..d7e630c --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/GetCreatorById.cs @@ -0,0 +1,48 @@ +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public sealed class GetCreatorByIdRequest +{ + public required Guid CreatorId { get; set; } +} + +[UsedImplicitly] +public sealed class GetCreatorByIdRequestValidator + : Validator +{ + public GetCreatorByIdRequestValidator() + { + RuleFor(r => r.CreatorId) + .NotNull().WithMessage("You should specify the CreatorId") + .NotEmpty().WithMessage("You should specify a valid/not empty CreatorId"); + } +} + +[PublicAPI] +public class GetCreatorByIdHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Get("/api/creators/{CreatorId}"); + Options((o => o.WithTags("Creators"))); + AllowAnonymous(); + } + + public override async Task HandleAsync( + GetCreatorByIdRequest req, + CancellationToken ct) + { + var creator = await context + .Creators + .FindAsync( + [req.CreatorId], + cancellationToken: ct); + + if (creator is null) await SendNotFoundAsync(ct); + else await SendAsync(creator, cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs b/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs new file mode 100644 index 0000000..cb27ca4 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs @@ -0,0 +1,31 @@ +using Hutopy.Web.Common; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public class GetCreatorProfileHandler( + ContentDbContext context) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/creators/profile"); + Options((o => o.WithTags("Creators"))); + AllowAnonymous(); + } + + public override async Task HandleAsync( + CancellationToken ct) + { + var creator = await context + .Creators + .FindAsync( + [HttpContext.User.GetUserId()], + cancellationToken: ct); + + if (creator is null) await SendNotFoundAsync(ct); + else await SendAsync(creator, cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs b/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs new file mode 100644 index 0000000..0a32f88 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/GetFeaturedContents.cs @@ -0,0 +1,66 @@ +using Hutopy.Web.Extensions; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Contents.Handlers.Models; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public sealed class GetFeaturedContentsRequest +{ + [BindFrom("page_size")] public int PageSize { get; set; } = 10; + [BindFrom("last_id")] public Guid? LastId { get; set; } +} + +[PublicAPI] +public class GetFeaturedContentsHandler( + ContentDbContext context) + : Endpoint> +{ + public override void Configure() + { + Get("/api/contents/featured"); + Options(o => o.WithTags("Contents")); + AllowAnonymous(); + } + + public override async Task HandleAsync( + GetFeaturedContentsRequest req, + CancellationToken ct) + { + var query = context.Contents + .Where(c => c.DeletedAt == null); + + if (req.LastId.HasValue) + { + query = query.Where(c => c.Id > req.LastId.Value); + } + + query = query.OrderByDescending(x => x.Reactions.Count); + + var content = await query + .Select(c => new ContentModel + { + Id = c.Id, + CreatedBy = c.CreatedBy, + CreatedByName = c.Creator!.Name, + CreatedByPortraitUrl = c.Creator.Images.Logo, + CreatedAt = c.CreatedAt, + DeletedBy = c.DeletedBy, + DeletedAt = c.DeletedAt, + Title = c.Title, + Description = c.Description, + Urls = c.Urls, + ThumbnailUrl = c.ThumbnailUrl, + Reactions = c.Reactions.Select(x => new ReactionModel + { + Reaction = x.Reaction.FromEnum(), + UserId = x.UserId, + UserName = x.UserName + }).ToList() + }) + .Take(req.PageSize) + .ToListAsync(ct); + + await SendAsync(content, cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/InsertImage.cs b/backend/src/Web/Features/Contents/Handlers/InsertImage.cs new file mode 100644 index 0000000..49c31b0 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/InsertImage.cs @@ -0,0 +1,86 @@ +using Hutopy.Web.Common.BlobStorage; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record InsertImagesRequest( + Guid Id, + Guid CreatorId, + IFormFileCollection? Files +); + +[PublicAPI] +public sealed class InsertImagesRequestValidator : Validator +{ + public InsertImagesRequestValidator() + { + RuleFor(r => r.Id) + .NotNull().WithMessage("You should specify the Id") + .NotEmpty().WithMessage("You should specify a valid/not empty Id"); + + RuleFor(r => r.CreatorId) + .NotNull().WithMessage("You should specify the CreatorId") + .NotEmpty().WithMessage("You should specify a valid/not empty CreatorId"); + } +} + +public sealed class InsertImages( + AzureBlobStorage blobStorage) + : Endpoint +{ + public override void Configure() + { + Post("/api/content/insert-image/"); + Options(o => o.WithTags("Contents")); + AllowFileUploads(); + } + + public override async Task HandleAsync( + InsertImagesRequest req, + CancellationToken ct) + { + var urls = new List(); + if (req.Files is not null) + { + await Parallel.ForEachAsync( + req.Files, + ct, + async ( + file, + ict) => + { + try + { + var contentUrl = await SaveFileAsync( + req.CreatorId, + req.Id, + file, + ict); + urls.Add(contentUrl); + } + catch (Exception ex) + { + Logger.LogError("{ErrorMessage}", ex.Message); + } + }); + } + + await SendOkAsync(urls, ct); + } + + private async Task SaveFileAsync( + Guid creatorId, + Guid contentId, + IFormFile file, + CancellationToken ct = default) + { + var url = await blobStorage.UploadFileAsync( + ContainerNames.Creators, + $"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/{file.FileName}", + file.OpenReadStream(), + file.ContentType, + ct: ct); + + return url; + } +} diff --git a/backend/src/Web/Features/Contents/Handlers/Models/ContentModel.cs b/backend/src/Web/Features/Contents/Handlers/Models/ContentModel.cs new file mode 100644 index 0000000..b063c2e --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/Models/ContentModel.cs @@ -0,0 +1,19 @@ +namespace Hutopy.Web.Features.Contents.Handlers.Models; + +[PublicAPI] +public class ContentModel +{ + public required Guid Id { get; init; } + public required Guid CreatedBy { get; init; } + public required string CreatedByName { get; init; } + public required string? CreatedByPortraitUrl { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public Guid? DeletedBy { get; init; } + public DateTimeOffset? DeletedAt { get; init; } + public required string Title { get; init; } + public required string Description { get; init; } + public string HtmlFileUrl { get; init; } = ""; + public required string[]? Urls { get; init; } + public string? ThumbnailUrl { get; init; } + public IList? Reactions { get; set; } = new List(); +} diff --git a/backend/src/Web/Features/Contents/Handlers/Models/FollowModel.cs b/backend/src/Web/Features/Contents/Handlers/Models/FollowModel.cs new file mode 100644 index 0000000..7f722f1 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/Models/FollowModel.cs @@ -0,0 +1,7 @@ +namespace Hutopy.Web.Features.Contents.Handlers.Models; + +[PublicAPI] +public record FollowModel( + Guid CreatorId, + string CreatorName, + string? CreatorPortraitUrl); diff --git a/backend/src/Web/Features/Contents/Handlers/Models/ReactionModel.cs b/backend/src/Web/Features/Contents/Handlers/Models/ReactionModel.cs new file mode 100644 index 0000000..514efd3 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/Models/ReactionModel.cs @@ -0,0 +1,8 @@ +namespace Hutopy.Web.Features.Contents.Handlers.Models; + +public class ReactionModel +{ + public required string Reaction { get; set; } + public required Guid UserId { get; set; } + public required string UserName { get; set; } +} diff --git a/backend/src/Web/Features/Contents/Handlers/RemoveReaction.cs b/backend/src/Web/Features/Contents/Handlers/RemoveReaction.cs new file mode 100644 index 0000000..04d2b43 --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/RemoveReaction.cs @@ -0,0 +1,36 @@ +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public sealed class RemoveReactionRequest +{ + public required Guid ContentId { get; set; } + public required Guid UserId { get; set; } +} + +[PublicAPI] +public class RemoveReaction( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/content/reaction/remove"); + Options(o => o.WithTags("Contents")); + } + + public override async Task HandleAsync( + RemoveReactionRequest req, + CancellationToken ct) + { + var content = await context.Contents + .SingleAsync(x => x.Id == req.ContentId, ct); + + var reaction = content.Reactions.Single(x => x.UserId == req.UserId); + + content.Reactions.Remove(reaction); + + await context.SaveChangesAsync(ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Data/Creator.cs b/backend/src/Web/Features/Memberships/Data/Creator.cs new file mode 100644 index 0000000..b536f28 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Creator.cs @@ -0,0 +1,9 @@ +namespace Hutopy.Web.Features.Memberships.Data; + +public class Creator +{ + public Guid Id { get; set; } + public string Name { get; set; } + public string? StripeAccountId { get; set; } + public string PortraitUrl { get; set; } +} diff --git a/backend/src/Web/Features/Memberships/Data/MembershipDbContext.cs b/backend/src/Web/Features/Memberships/Data/MembershipDbContext.cs new file mode 100644 index 0000000..f0dcd47 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/MembershipDbContext.cs @@ -0,0 +1,58 @@ +namespace Hutopy.Web.Features.Memberships.Data; + +public sealed class MembershipDbContext( + DbContextOptions options) + : DbContext(options) +{ + public const string SchemaName = "Membership"; + + public DbSet Creators => Set(); + public DbSet Subscriptions => Set(); + public DbSet Tiers => Set(); + public DbSet Tips => Set(); + public DbSet Transactions => Set(); + + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(); + + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + modelBuilder + .Entity() + .HasOne(c => c.Creator) + .WithMany() + .HasForeignKey(c => c.CreatorId); + + modelBuilder + .Entity() + .HasOne(c => c.Creator) + .WithMany() + .HasForeignKey(c => c.CreatorId); + + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + } +} diff --git a/backend/src/Web/Features/Memberships/Data/MembershipDbContextInitializer.cs b/backend/src/Web/Features/Memberships/Data/MembershipDbContextInitializer.cs new file mode 100644 index 0000000..80d3937 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/MembershipDbContextInitializer.cs @@ -0,0 +1,34 @@ +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Memberships.Data; + +public static class InitializerExtensions +{ + public static async Task InitialiseMembershipDbContextAsync(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + + var initializer = scope.ServiceProvider.GetRequiredService(); + + await initializer.InitialiseAsync(); + } +} + +public class MembershipDbContextInitializer( + ILogger logger, + MembershipDbContext context + ) +{ + public async Task InitialiseAsync() + { + try + { + await context.Database.MigrateAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while initialising the membership database."); + throw; + } + } +} diff --git a/backend/src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.Designer.cs b/backend/src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.Designer.cs new file mode 100644 index 0000000..a392276 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.Designer.cs @@ -0,0 +1,288 @@ +// +using System; +using Hutopy.Web.Features.Memberships.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.Memberships.Data.Migrations +{ + [DbContext(typeof(MembershipDbContext))] + [Migration("20241022191000_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Membership") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeAccountId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Creators", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeSessionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StripeSubscriptionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TierId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.HasIndex("TierId"); + + b.ToTable("Subscriptions", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("StripePriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StripeProductId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Tiers", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CreatorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeSessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TipperId") + .HasColumnType("uuid"); + + b.Property("TipperName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransactionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId"); + + b.ToTable("Tips", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeInvoiceUrl") + .HasColumnType("text"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Transactions", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier") + .WithMany("Subscriptions") + .HasForeignKey("TierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Tier"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction") + .WithMany() + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null) + .WithMany("Transactions") + .HasForeignKey("SubscriptionId"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.Navigation("Subscriptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.cs b/backend/src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.cs new file mode 100644 index 0000000..60ee58d --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.cs @@ -0,0 +1,201 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Memberships.Data.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "Membership"); + + migrationBuilder.CreateTable( + name: "Creators", + schema: "Membership", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + StripeAccountId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Creators", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Tiers", + schema: "Membership", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + CreatorId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Description = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: false), + Price = table.Column(type: "numeric", nullable: false), + CurrencyCode = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + StripeProductId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + StripePriceId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tiers", x => x.Id); + table.ForeignKey( + name: "FK_Tiers_Creators_CreatorId", + column: x => x.CreatorId, + principalSchema: "Membership", + principalTable: "Creators", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Subscriptions", + schema: "Membership", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UserId = table.Column(type: "uuid", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: false), + TierId = table.Column(type: "uuid", nullable: false), + StartDate = table.Column(type: "timestamp with time zone", nullable: false), + EndDate = table.Column(type: "timestamp with time zone", nullable: true), + StripeSessionId = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + StripeSubscriptionId = table.Column(type: "character varying(255)", maxLength: 255, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Subscriptions", x => x.Id); + table.ForeignKey( + name: "FK_Subscriptions_Creators_CreatorId", + column: x => x.CreatorId, + principalSchema: "Membership", + principalTable: "Creators", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Subscriptions_Tiers_TierId", + column: x => x.TierId, + principalSchema: "Membership", + principalTable: "Tiers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Transactions", + schema: "Membership", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + Amount = table.Column(type: "numeric", nullable: false), + Currency = table.Column(type: "text", nullable: false), + Type = table.Column(type: "text", nullable: false), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false), + StripeInvoiceUrl = table.Column(type: "text", nullable: true), + SubscriptionId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Transactions", x => x.Id); + table.ForeignKey( + name: "FK_Transactions_Subscriptions_SubscriptionId", + column: x => x.SubscriptionId, + principalSchema: "Membership", + principalTable: "Subscriptions", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "Tips", + schema: "Membership", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + StripeSessionId = table.Column(type: "text", nullable: false), + TipperId = table.Column(type: "uuid", nullable: false), + TipperName = table.Column(type: "text", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: false), + CreatorName = table.Column(type: "text", nullable: false), + Amount = table.Column(type: "numeric", nullable: false), + Currency = table.Column(type: "text", nullable: false), + Message = table.Column(type: "text", nullable: false), + TransactionId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tips", x => x.Id); + table.ForeignKey( + name: "FK_Tips_Transactions_TransactionId", + column: x => x.TransactionId, + principalSchema: "Membership", + principalTable: "Transactions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_CreatorId", + schema: "Membership", + table: "Subscriptions", + column: "CreatorId"); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_TierId", + schema: "Membership", + table: "Subscriptions", + column: "TierId"); + + migrationBuilder.CreateIndex( + name: "IX_Tiers_CreatorId", + schema: "Membership", + table: "Tiers", + column: "CreatorId"); + + migrationBuilder.CreateIndex( + name: "IX_Tips_TransactionId", + schema: "Membership", + table: "Tips", + column: "TransactionId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_SubscriptionId", + schema: "Membership", + table: "Transactions", + column: "SubscriptionId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Tips", + schema: "Membership"); + + migrationBuilder.DropTable( + name: "Transactions", + schema: "Membership"); + + migrationBuilder.DropTable( + name: "Subscriptions", + schema: "Membership"); + + migrationBuilder.DropTable( + name: "Tiers", + schema: "Membership"); + + migrationBuilder.DropTable( + name: "Creators", + schema: "Membership"); + } + } +} diff --git a/backend/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.Designer.cs b/backend/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.Designer.cs new file mode 100644 index 0000000..74c8285 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.Designer.cs @@ -0,0 +1,292 @@ +// +using System; +using Hutopy.Web.Features.Memberships.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.Memberships.Data.Migrations +{ + [DbContext(typeof(MembershipDbContext))] + [Migration("20241022203207_PortraitUrlToCreator")] + partial class PortraitUrlToCreator + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Membership") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortraitUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeAccountId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Creators", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeSessionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StripeSubscriptionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TierId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.HasIndex("TierId"); + + b.ToTable("Subscriptions", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("StripePriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StripeProductId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Tiers", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CreatorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeSessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TipperId") + .HasColumnType("uuid"); + + b.Property("TipperName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransactionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId"); + + b.ToTable("Tips", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeInvoiceUrl") + .HasColumnType("text"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Transactions", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier") + .WithMany("Subscriptions") + .HasForeignKey("TierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Tier"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction") + .WithMany() + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null) + .WithMany("Transactions") + .HasForeignKey("SubscriptionId"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.Navigation("Subscriptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.cs b/backend/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.cs new file mode 100644 index 0000000..47dc9c9 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Memberships.Data.Migrations +{ + /// + public partial class PortraitUrlToCreator : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PortraitUrl", + schema: "Membership", + table: "Creators", + type: "text", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PortraitUrl", + schema: "Membership", + table: "Creators"); + } + } +} diff --git a/backend/src/Web/Features/Memberships/Data/Migrations/20241216215210_UpdateSeedData.Designer.cs b/backend/src/Web/Features/Memberships/Data/Migrations/20241216215210_UpdateSeedData.Designer.cs new file mode 100644 index 0000000..dc927cf --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Migrations/20241216215210_UpdateSeedData.Designer.cs @@ -0,0 +1,292 @@ +// +using System; +using Hutopy.Web.Features.Memberships.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.Memberships.Data.Migrations +{ + [DbContext(typeof(MembershipDbContext))] + [Migration("20241216215210_UpdateSeedData")] + partial class UpdateSeedData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Membership") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortraitUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeAccountId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Creators", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeSessionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StripeSubscriptionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TierId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.HasIndex("TierId"); + + b.ToTable("Subscriptions", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("StripePriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StripeProductId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Tiers", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CreatorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeSessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TipperId") + .HasColumnType("uuid"); + + b.Property("TipperName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransactionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId"); + + b.ToTable("Tips", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeInvoiceUrl") + .HasColumnType("text"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Transactions", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier") + .WithMany("Subscriptions") + .HasForeignKey("TierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Tier"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction") + .WithMany() + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null) + .WithMany("Transactions") + .HasForeignKey("SubscriptionId"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.Navigation("Subscriptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Memberships/Data/Migrations/20241216215210_UpdateSeedData.cs b/backend/src/Web/Features/Memberships/Data/Migrations/20241216215210_UpdateSeedData.cs new file mode 100644 index 0000000..cb5b667 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Migrations/20241216215210_UpdateSeedData.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Memberships.Data.Migrations +{ + /// + public partial class UpdateSeedData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/backend/src/Web/Features/Memberships/Data/Migrations/MembershipDbContextModelSnapshot.cs b/backend/src/Web/Features/Memberships/Data/Migrations/MembershipDbContextModelSnapshot.cs new file mode 100644 index 0000000..8539153 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Migrations/MembershipDbContextModelSnapshot.cs @@ -0,0 +1,289 @@ +// +using System; +using Hutopy.Web.Features.Memberships.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hutopy.Web.Features.Memberships.Data.Migrations +{ + [DbContext(typeof(MembershipDbContext))] + partial class MembershipDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Membership") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortraitUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeAccountId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Creators", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeSessionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StripeSubscriptionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TierId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.HasIndex("TierId"); + + b.ToTable("Subscriptions", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("StripePriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StripeProductId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Tiers", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CreatorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeSessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TipperId") + .HasColumnType("uuid"); + + b.Property("TipperName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransactionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId"); + + b.ToTable("Tips", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeInvoiceUrl") + .HasColumnType("text"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Transactions", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier") + .WithMany("Subscriptions") + .HasForeignKey("TierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Tier"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction") + .WithMany() + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null) + .WithMany("Transactions") + .HasForeignKey("SubscriptionId"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.Navigation("Subscriptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Memberships/Data/Subscription.cs b/backend/src/Web/Features/Memberships/Data/Subscription.cs new file mode 100644 index 0000000..67b3ad1 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Subscription.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Hutopy.Web.Features.Memberships.Data; + +public class Subscription +{ + public Guid Id { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public Guid UserId { get; set; } + public Guid CreatorId { get; set; } + public Creator Creator { get; set; } + public Guid TierId { get; set; } + public Tier Tier { get; set; } + public DateTimeOffset StartDate { get; set; } + public DateTimeOffset? EndDate { get; set; } + public bool IsActive => EndDate == null || EndDate > DateTimeOffset.UtcNow; + [MaxLength(255)]public string? StripeSessionId { get; set; } + [MaxLength(255)]public string? StripeSubscriptionId { get; set; } + + public ICollection Transactions { get; set; } = []; +} diff --git a/backend/src/Web/Features/Memberships/Data/Tier.cs b/backend/src/Web/Features/Memberships/Data/Tier.cs new file mode 100644 index 0000000..ec35cc6 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Tier.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Hutopy.Web.Features.Memberships.Data; + +public class Tier +{ + public Guid Id { get; set; } + public DateTime CreatedAt { get; set; } + public Guid CreatorId { get; set; } + public Creator Creator { get; set; } = null!; + [MaxLength(128)] public string Name { get; set; } = null!; + [MaxLength(4096)] public string Description { get; set; } = null!; + public decimal Price { get; set; } + [MaxLength(128)] public string CurrencyCode { get; set; } = null!; + [MaxLength(128)] public string StripeProductId { get; set; } = null!; + [MaxLength(128)] public string StripePriceId { get; set; } = null!; + + public ICollection Subscriptions { get; set; } = []; +} diff --git a/backend/src/Web/Features/Memberships/Data/Tip.cs b/backend/src/Web/Features/Memberships/Data/Tip.cs new file mode 100644 index 0000000..0f4bd09 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Tip.cs @@ -0,0 +1,18 @@ +namespace Hutopy.Web.Features.Memberships.Data; + +public class Tip +{ + public Guid Id { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public string StripeSessionId { get; set; } + public Guid TipperId { get; set; } + public string TipperName { get; set; } + public Guid CreatorId { get; set; } + public string CreatorName { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } + public string Message { get; set; } + + public Guid TransactionId { get; set; } + public Transaction Transaction { get; set; } +} diff --git a/backend/src/Web/Features/Memberships/Data/Transaction.cs b/backend/src/Web/Features/Memberships/Data/Transaction.cs new file mode 100644 index 0000000..5b62f41 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Data/Transaction.cs @@ -0,0 +1,12 @@ +namespace Hutopy.Web.Features.Memberships.Data; + +public class Transaction +{ + public Guid Id { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } + public string Type { get; set; } // Subscription, Tip + public DateTime Timestamp { get; set; } + public string? StripeInvoiceUrl { get; set; } +} diff --git a/backend/src/Web/Features/Memberships/DependencyInjection.cs b/backend/src/Web/Features/Memberships/DependencyInjection.cs new file mode 100644 index 0000000..eb14bd1 --- /dev/null +++ b/backend/src/Web/Features/Memberships/DependencyInjection.cs @@ -0,0 +1,27 @@ +using Hutopy.Web.Features.Memberships.Data; +using Hutopy.Web.Features.Memberships.Infrastructure; + +namespace Hutopy.Web.Features.Memberships; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddMembershipModule( + this WebApplicationBuilder builder, + Action? configureAction = null) + { + builder.Services.AddSingleton(); + + builder.Services.AddDbContext(configureAction); + builder.Services.AddScoped(); + + builder.Services.AddScoped(); + + builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection("Stripe")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + return builder; + } +} diff --git a/backend/src/Web/Features/Memberships/Events/StripeAccountConfigured.cs b/backend/src/Web/Features/Memberships/Events/StripeAccountConfigured.cs new file mode 100644 index 0000000..cb91976 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Events/StripeAccountConfigured.cs @@ -0,0 +1,5 @@ +namespace Hutopy.Web.Features.Memberships.Events; + +public record StripeAccountConfigured( + Guid CreatorId, + string StripeAccountId); diff --git a/backend/src/Web/Features/Memberships/Events/SubscriptionPaid.cs b/backend/src/Web/Features/Memberships/Events/SubscriptionPaid.cs new file mode 100644 index 0000000..b661b35 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Events/SubscriptionPaid.cs @@ -0,0 +1,7 @@ +namespace Hutopy.Web.Features.Memberships.Events; + +public record struct SubscriptionPaid( + Guid CreatorId, + string CreatorName, + string Tier, + DateTimeOffset Since); diff --git a/backend/src/Web/Features/Memberships/Events/TipPaid.cs b/backend/src/Web/Features/Memberships/Events/TipPaid.cs new file mode 100644 index 0000000..6331757 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Events/TipPaid.cs @@ -0,0 +1,8 @@ +namespace Hutopy.Web.Features.Memberships.Events; + +public record struct TipPaid( + Guid CreatorId, + string CreatorName, + decimal Amount, + string Currency, + string Message); diff --git a/backend/src/Web/Features/Memberships/Handlers/CancelSubscription.cs b/backend/src/Web/Features/Memberships/Handlers/CancelSubscription.cs new file mode 100644 index 0000000..6acc122 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Handlers/CancelSubscription.cs @@ -0,0 +1,48 @@ +using Hutopy.Web.Features.Memberships.Data; +using Hutopy.Web.Features.Memberships.Infrastructure; + +namespace Hutopy.Web.Features.Memberships.Handlers; + +[PublicAPI] +public class CancelSubscriptionRequest +{ + public Guid SubscriptionId { get; set; } +} + +public class CancelSubscriptionHandler( + MembershipDbContext dbContext, + StripeService stripeService) + : Endpoint +{ + public override void Configure() + { + Post("/api/membership/unsubscribe"); + Options(o => o.WithTags("Memberships")); + } + + public override async Task HandleAsync( + CancelSubscriptionRequest req, + CancellationToken ct) + { + var subscription = await dbContext + .Subscriptions + .FindAsync( + [req.SubscriptionId], + cancellationToken: ct); + + if (subscription is not { EndDate: null }) + { + await SendNotFoundAsync(ct); + return; + } + + // Cancel Stripe subscription + await stripeService.CancelSubscription(subscription.Id); + + // Update subscription in the system + subscription.EndDate = DateTime.UtcNow; + await dbContext.SaveChangesAsync(ct); + + await SendOkAsync(subscription.Id, ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Handlers/ChangeStripeId.cs b/backend/src/Web/Features/Memberships/Handlers/ChangeStripeId.cs new file mode 100644 index 0000000..9a3dae0 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Handlers/ChangeStripeId.cs @@ -0,0 +1,55 @@ +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Memberships.Data; +using Hutopy.Web.Features.Memberships.Events; + +namespace Hutopy.Web.Features.Memberships.Handlers; + +[PublicAPI] +public record struct ChangeStripeIdRequest( + string StripeAccountId); + +public class ChangeStripeIdHandler( + MembershipDbContext dbContext) + : Endpoint +{ + public override void Configure() + { + Post("/api/membership/stripe-account"); + Options(o => o.WithTags("Memberships")); + } + + public override async Task HandleAsync( + ChangeStripeIdRequest req, + CancellationToken ct) + { + var creatorId = HttpContext.User.GetUserId(); + + var creator = await dbContext + .Creators + .FindAsync( + [creatorId], + cancellationToken: ct); + + if (creator is null) + { + creator = new Creator + { + Id = creatorId, + Name = HttpContext.User.GetAlias() ?? creatorId.ToString(), + PortraitUrl = HttpContext.User.GetPortraitUrl() ?? string.Empty + }; + + await dbContext.AddAsync(creator, ct); + } + + creator.StripeAccountId = req.StripeAccountId; + + await dbContext.SaveChangesAsync(ct); + + await PublishAsync( + new StripeAccountConfigured(creator.Id, creator.StripeAccountId), + cancellation: ct); + + await SendOkAsync(creator.Id, ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Handlers/CreateMembershipTier.cs b/backend/src/Web/Features/Memberships/Handlers/CreateMembershipTier.cs new file mode 100644 index 0000000..5f7925f --- /dev/null +++ b/backend/src/Web/Features/Memberships/Handlers/CreateMembershipTier.cs @@ -0,0 +1,56 @@ +using Hutopy.Web.Features.Memberships.Data; +using Hutopy.Web.Features.Memberships.Infrastructure; + +namespace Hutopy.Web.Features.Memberships.Handlers; + +[PublicAPI] +public record struct CreateMembershipTierRequest( + Guid CreatorId, + string Name, + string Description, + decimal Price, + string Currency = "CAD"); + +[PublicAPI] +public class CreateMembershipTierEndpoint( + MembershipDbContext dbContext, + StripeService stripe) + : Endpoint +{ + public override void Configure() + { + Post("/api/membership/tiers"); + Options(o => o.WithTags("Memberships")); + } + + public override async Task HandleAsync( + CreateMembershipTierRequest req, + CancellationToken ct) + { + var tierId = Guid.NewGuid(); + + var productId = await stripe.CreateProductAsync( + req.CreatorId, + tierId, + req.Name, + req.Currency, + req.Price); + + // Record the new Tier + var tier = new Tier + { + Id = tierId, + CreatorId = req.CreatorId, + Price = req.Price, + Name = req.Name, + Description = req.Description, + StripeProductId = productId, + }; + + dbContext.Tiers.Add(tier); + + await dbContext.SaveChangesAsync(ct); + + await SendOkAsync(tier, ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Handlers/GetActiveSubscriptions.cs b/backend/src/Web/Features/Memberships/Handlers/GetActiveSubscriptions.cs new file mode 100644 index 0000000..a42601f --- /dev/null +++ b/backend/src/Web/Features/Memberships/Handlers/GetActiveSubscriptions.cs @@ -0,0 +1,44 @@ +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Memberships.Data; + +namespace Hutopy.Web.Features.Memberships.Handlers; + +[PublicAPI] +public record struct GetActiveSubscriptionsResponse( + Guid Id, + Guid CreatorId, + string CreatorName, + string CreatorPortraitUrl, + DateTimeOffset StartDate, + DateTimeOffset? EndDate); + +[PublicAPI] +public class GetActiveSubscriptionsHandler( + MembershipDbContext dbContext) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/membership/active"); + Options(o => o.WithTags("Memberships")); + } + + public override async Task HandleAsync( + CancellationToken ct) + { + var subscriptions = await dbContext + .Subscriptions + .Where(subscription => subscription.UserId == User.GetUserId()) + .Where(subscription => subscription.EndDate == null || subscription.EndDate > DateTimeOffset.UtcNow) + .Select(subscription => new GetActiveSubscriptionsResponse( + subscription.Id, + subscription.Creator.Id, + subscription.Creator.Name, + subscription.Creator.PortraitUrl, + subscription.StartDate, + subscription.EndDate)) + .ToListAsync(ct); + + await SendOkAsync(subscriptions, ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Handlers/GetMembershipTier.cs b/backend/src/Web/Features/Memberships/Handlers/GetMembershipTier.cs new file mode 100644 index 0000000..a3981cc --- /dev/null +++ b/backend/src/Web/Features/Memberships/Handlers/GetMembershipTier.cs @@ -0,0 +1,52 @@ +using Hutopy.Web.Features.Memberships.Data; + +namespace Hutopy.Web.Features.Memberships.Handlers; + +[PublicAPI] +public record GetMembershipTierRequest +{ + public Guid CreatorId { get; set; } +} + +[PublicAPI] +public record struct TierModel( + Guid Id, + DateTime CreatedAt, + string Name, + string Description, + decimal Price, + string CurrencyCode, + string StripeProductId); + +[PublicAPI] +public class GetMembershipTierEndpoint( + MembershipDbContext dbContext) + : Endpoint> +{ + public override void Configure() + { + Get("/api/membership/tiers/{CreatorId:guid}"); + Options(o => o.WithTags("Memberships")); + AllowAnonymous(); + } + + public override async Task HandleAsync( + GetMembershipTierRequest req, + CancellationToken ct) + { + var tiers = await dbContext + .Tiers + .Where(tier => tier.CreatorId == req.CreatorId) + .Select(tier => new TierModel( + tier.Id, + tier.CreatedAt, + tier.Name, + tier.Description, + tier.Price, + tier.CurrencyCode, + tier.StripeProductId)) + .ToListAsync(ct); + + await SendOkAsync(tiers, ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Handlers/GetReceivedTips.cs b/backend/src/Web/Features/Memberships/Handlers/GetReceivedTips.cs new file mode 100644 index 0000000..85e1d7f --- /dev/null +++ b/backend/src/Web/Features/Memberships/Handlers/GetReceivedTips.cs @@ -0,0 +1,46 @@ +using Hutopy.Web.Common; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Memberships.Data; + +namespace Hutopy.Web.Features.Memberships.Handlers; + +[PublicAPI] +public record struct TipReceivedModel( + Guid Id, + DateTimeOffset CreatedAt, + Guid TipperId, + string TipperName, + decimal Amount, + string Currency, + string Message); + +[PublicAPI] +public class GetReceivedTipsHandler( + MembershipDbContext dbContext) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/tips/received"); + Options(o => o.WithTags("Memberships")); + } + + public override async Task HandleAsync( + CancellationToken ct) + { + var tipsReceived = await dbContext + .Tips + .Where(tip => tip.CreatorId == User.GetUserId()) + .Select(tip => new TipReceivedModel( + tip.Id, + tip.CreatedAt, + tip.TipperId, + tip.TipperName, + tip.Amount, + tip.Currency, + tip.Message)) + .ToListAsync(ct); + + await SendOkAsync(tipsReceived, ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Handlers/GetSentTips.cs b/backend/src/Web/Features/Memberships/Handlers/GetSentTips.cs new file mode 100644 index 0000000..70c1b52 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Handlers/GetSentTips.cs @@ -0,0 +1,46 @@ +using Hutopy.Web.Common; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Memberships.Data; + +namespace Hutopy.Web.Features.Memberships.Handlers; + +[PublicAPI] +public record struct TipSentModel( + Guid Id, + DateTimeOffset CreatedAt, + Guid CreatorId, + string CreatorName, + decimal Amount, + string Currency, + string Message); + +[PublicAPI] +public class GetSentTipsHandler( + MembershipDbContext dbContext) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/tips/sent"); + Options(o => o.WithTags("Memberships")); + } + + public override async Task HandleAsync( + CancellationToken ct) + { + var tips = await dbContext + .Tips + .Where(t => t.TipperId == User.GetUserId()) + .Select(tip => new TipSentModel( + tip.Id, + tip.CreatedAt, + tip.CreatorId, + tip.CreatorName, + tip.Amount, + tip.Currency, + tip.Message)) + .ToListAsync(ct); + + await SendOkAsync(tips, ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Handlers/HandleStripe.cs b/backend/src/Web/Features/Memberships/Handlers/HandleStripe.cs new file mode 100644 index 0000000..5578604 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Handlers/HandleStripe.cs @@ -0,0 +1,51 @@ +using Hutopy.Web.Features.Memberships.Infrastructure; +using Microsoft.Extensions.Options; +using Stripe; + +namespace Hutopy.Web.Features.Memberships.Handlers; + +public class StripeWebhookEndpoint( + StripeService stripeService, + IOptions options) + : EndpointWithoutRequest +{ + public override void Configure() + { + Post("/api/stripe"); + AllowAnonymous(); + Options(o => o.WithTags("Memberships")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + using var streamReader = new StreamReader(HttpContext.Request.Body); + var json = await streamReader.ReadToEndAsync(ct); + + var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"]; + var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret); + + switch (stripeEvent.Type) + { + case "checkout.session.completed": + await stripeService.HandleCheckoutSessionCompleted(stripeEvent, ct); + break; + case "invoice.payment_succeeded": + await stripeService.HandleInvoicePaymentSucceeded(stripeEvent, ct); + break; + case "invoice.payment_failed": + await stripeService.HandleInvoicePaymentFailed(stripeEvent, ct); + break; + case "customer.subscription.created": + await stripeService.HandleCustomerSubscriptionCreated(stripeEvent, ct); + break; + case "customer.subscription.updated": + await stripeService.HandleCustomerSubscriptionUpdated(stripeEvent, ct); + break; + case "customer.subscription.deleted": + await stripeService.HandleCustomerSubscriptionDeleted(stripeEvent, ct); + break; + } + + await SendOkAsync(ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Handlers/SendTip.cs b/backend/src/Web/Features/Memberships/Handlers/SendTip.cs new file mode 100644 index 0000000..b845ca9 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Handlers/SendTip.cs @@ -0,0 +1,87 @@ +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Memberships.Data; +using Hutopy.Web.Features.Memberships.Infrastructure; + +namespace Hutopy.Web.Features.Memberships.Handlers; + +[PublicAPI] +public record SendTipRequest( + Guid CreatorId, + decimal Amount, + string Currency, + string Message, + string CheckoutSuccessUrl, + string CheckoutCancelledUrl); + +[PublicAPI] +public record SendTipResponse( + string Status, + string StripeCheckoutUrl); + +[PublicAPI] +public class SendTipRequestValidator : Validator +{ + public SendTipRequestValidator() + { + RuleFor(x => x.Amount) + .GreaterThan(0) + .WithMessage("Tip amount must be greater than 0"); + + RuleFor(x => x.CreatorId) + .NotEmpty() + .WithMessage("Creator ID is required"); + + RuleFor(x => x.CheckoutSuccessUrl) + .NotEmpty() + .WithMessage("CheckoutSuccessUrl is required"); + + RuleFor(x => x.CheckoutCancelledUrl) + .NotEmpty() + .WithMessage("CheckoutCancelledUrl is required"); + } +} + +[PublicAPI] +public class SendTipHandler( + MembershipDbContext dbContext, + StripeService stripeService) + : Endpoint +{ + public override void Configure() + { + Post("/api/tips"); + Options(o => o.WithTags("Memberships")); + + AllowAnonymous(); + } + + public override async Task HandleAsync( + SendTipRequest req, + CancellationToken ct) + { + var creator = await dbContext.Creators.FindAsync( + [req.CreatorId], + cancellationToken: ct); + + if (creator == null) + { + await SendNotFoundAsync(ct); + return; + } + + var checkoutSession = await stripeService.CreateTipCheckoutSessionAsync( + creator.Id, + creator.Name, + req.Amount, + req.Currency, + req.Message, + creator.StripeAccountId, + req.CheckoutSuccessUrl, + req.CheckoutCancelledUrl + ); + + await SendAsync( + new SendTipResponse("Pending", checkoutSession.Url), + cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs b/backend/src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs new file mode 100644 index 0000000..1b9cb82 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs @@ -0,0 +1,72 @@ +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Memberships.Data; +using Hutopy.Web.Features.Memberships.Infrastructure; + +namespace Hutopy.Web.Features.Memberships.Handlers; + +[PublicAPI] +public class SubscribeRequest +{ + public Guid CreatorId { get; set; } + public Guid TierId { get; set; } + public required string CheckoutSuccessUrl { get; init; } + public required string CheckoutCancelledUrl { get; init; } +} + +[PublicAPI] +public record struct SubscriptionResponse( + string StripeCheckoutUrl); + +[PublicAPI] +public class SubscribeValidator : Validator +{ + public SubscribeValidator() + { + RuleFor(x => x.TierId).NotEmpty(); + } +} + +[PublicAPI] +public class SubscribeHandler( + MembershipDbContext dbContext, + StripeService stripeService) + : Endpoint +{ + public override void Configure() + { + Post("/api/membership/subscribe"); + Options(o => o.WithTags("Memberships")); + } + + public override async Task HandleAsync( + SubscribeRequest req, + CancellationToken ct) + { + var tier = await dbContext + .Tiers + .Include(tier => tier.Creator) // Include the related table + .Where(tier => tier.Id == req.TierId) + .FirstOrDefaultAsync(ct); + + if (tier == null) + { + await SendNotFoundAsync(ct); + return; + } + + // Process Stripe subscription + var checkoutSession = await stripeService.CreateSubscriptionCheckoutSession( + User.GetUserId(), + tier.Creator.Id, + tier.Creator.Name, + tier.Creator.StripeAccountId, + tier.Id, + tier.StripePriceId, + req.CheckoutSuccessUrl, + req.CheckoutCancelledUrl); + + await SendOkAsync( + new SubscriptionResponse { StripeCheckoutUrl = checkoutSession.Url }, + cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Memberships/Infrastructure/PushNotificationService.cs b/backend/src/Web/Features/Memberships/Infrastructure/PushNotificationService.cs new file mode 100644 index 0000000..425b323 --- /dev/null +++ b/backend/src/Web/Features/Memberships/Infrastructure/PushNotificationService.cs @@ -0,0 +1,13 @@ +namespace Hutopy.Web.Features.Memberships.Infrastructure; + +public sealed class PushNotificationService( + ILogger logger) +{ + public void NotifyCreator( + Guid tipCreatorId, + TEvent notification) + where TEvent : struct + { + logger.LogInformation("Notifying creator {CreatorId}, {Notification}", tipCreatorId, notification); + } +} diff --git a/backend/src/Web/Features/Memberships/Infrastructure/StripeService.cs b/backend/src/Web/Features/Memberships/Infrastructure/StripeService.cs new file mode 100644 index 0000000..c1db0ec --- /dev/null +++ b/backend/src/Web/Features/Memberships/Infrastructure/StripeService.cs @@ -0,0 +1,428 @@ +using System.ComponentModel.DataAnnotations; +using Hutopy.Web.Features.Memberships.Data; +using Hutopy.Web.Features.Memberships.Events; +using Microsoft.Extensions.Options; +using Stripe; +using Stripe.Checkout; +using Subscription = Stripe.Subscription; + +namespace Hutopy.Web.Features.Memberships.Infrastructure; + +public class StripeOptions +{ + [Required] public required string SecretKey { get; init; } + + [Required] public required string WebhookSecret { get; init; } + + [Required] [Range(0, 1)] public required decimal HutopyRate { get; init; } +} + +public sealed class StripeService( + IOptions paymentOptions, + MembershipDbContext dbContext, + PushNotificationService notificationService) +{ + public async Task CreateProductAsync( + Guid creatorId, + Guid tierId, + string productName, + string currencyCode, + decimal amount) + { + StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey; + + // Create the product + var productService = new ProductService(); + var product = await productService.CreateAsync( + new ProductCreateOptions + { + Name = productName, + Metadata = { { "creatorId", creatorId.ToString() }, { "tierId", tierId.ToString() } } + }); + + // Create the price for the product + var priceService = new PriceService(); + await priceService.CreateAsync( + new PriceCreateOptions + { + Product = product.Id, + UnitAmountDecimal = amount * 100, // Convert amount to cents + Currency = currencyCode, + Recurring = new PriceRecurringOptions { Interval = "month" } + }); + + return product.Id; + } + + public async Task CreateTipCheckoutSessionAsync( + Guid creatorId, + string creatorName, + decimal amount, + string currencyCode, + string message, + string creatorAccountId, + string successUrl, + string cancelUrl, + CancellationToken ct = default) + { + StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey; + + // Create Stripe customer for the user if not already created + var customerService = new CustomerService(); + var customer = await customerService.CreateAsync( + new CustomerCreateOptions{}, + cancellationToken: ct); + + // Create paymentIntent for the user + var sessionService = new SessionService(); + return await sessionService.CreateAsync( + new SessionCreateOptions + { + Customer = customer.Id, + PaymentMethodTypes = ["card"], + LineItems = + [ + new SessionLineItemOptions + { + PriceData = new SessionLineItemPriceDataOptions + { + Currency = currencyCode, + UnitAmountDecimal = amount, // Amount in cents + ProductData = new SessionLineItemPriceDataProductDataOptions + { + Name = $"Tip for {creatorName}", // or any descriptive name for the tip + Metadata = new Dictionary { { "creatorId", creatorId.ToString() } } + } + }, + Quantity = 1 + } + ], + Mode = "payment", + PaymentIntentData = new SessionPaymentIntentDataOptions + { + ApplicationFeeAmount = + Convert.ToInt64(amount * 100 * paymentOptions.Value.HutopyRate), // Platform fee + TransferData = new SessionPaymentIntentDataTransferDataOptions + { + Destination = creatorAccountId // Creator's Stripe account ID + } + }, + SuccessUrl = successUrl, // Redirect after successful payment + CancelUrl = cancelUrl, // Redirect after canceled payment + Metadata = new Dictionary + { + { "creatorId", creatorId.ToString() }, + { "creatorName", creatorName }, + { "message", message }, + } + }, + cancellationToken: ct); + } + + public async Task CreateSubscriptionCheckoutSession( + Guid userId, + Guid creatorId, + string creatorName, + string creatorAccountId, + Guid tierId, + string priceId, + string successUrl, + string cancelUrl) + { + StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey; + + // Create Stripe customer for the user if not already created + var customerService = new CustomerService(); + var customer = await customerService.CreateAsync( + new CustomerCreateOptions + { + Metadata = new Dictionary { { "userId", userId.ToString() } } + }); + + // Create Checkout Session for the subscription + var sessionService = new SessionService(); + return await sessionService.CreateAsync( + new SessionCreateOptions + { + Customer = customer.Id, + PaymentMethodTypes = ["card"], + LineItems = + [ + new SessionLineItemOptions { Price = priceId, Quantity = 1 } + ], + Mode = "subscription", + SubscriptionData = new SessionSubscriptionDataOptions + { + ApplicationFeePercent = paymentOptions.Value.HutopyRate, + TransferData = new SessionSubscriptionDataTransferDataOptions { Destination = creatorAccountId } + }, + SuccessUrl = successUrl, // Redirect after successful payment + CancelUrl = cancelUrl, // Redirect after canceled payment + Metadata = new Dictionary + { + { "userId", userId.ToString() }, + { "creatorId", creatorId.ToString() }, + { "creatorName", creatorName }, + { "tierId", tierId.ToString() } + } + }); + } + + public async Task CancelSubscription( + Guid subscriptionId) + { + var subscriptionService = new SubscriptionService(); + await subscriptionService.CancelAsync(subscriptionId.ToString()); + } + + public async Task HandleInvoicePaymentSucceeded( + Event stripeEvent, + CancellationToken ct = default) + { + // Ensure we have an invoice related to a Subscription + if (stripeEvent.Data.Object is not Invoice { Subscription: not null } invoice) + { + return; + } + + var subscription = await dbContext + .Subscriptions + .FirstOrDefaultAsync( + subscription => subscription.StripeSubscriptionId == invoice.Subscription.Id, + cancellationToken: ct); + + if (subscription == null) + { + return; + } + + // Record the Transaction + var transaction = new Transaction + { + Id = Guid.NewGuid(), + CreatedAt = DateTimeOffset.UtcNow, + Amount = invoice.AmountPaid / 100m, // Convert amount from cents to dollars + Currency = invoice.Currency, + Type = "Subscription", + Timestamp = DateTime.UtcNow, + StripeInvoiceUrl = invoice.HostedInvoiceUrl + }; + + dbContext.Transactions.Add(transaction); + + // Link the Transaction to the Subscription + subscription.Transactions.Add(transaction); + + await dbContext.SaveChangesAsync(ct); + } + + public async Task HandleInvoicePaymentFailed( + Event stripeEvent, + CancellationToken ct = default) + { + if (stripeEvent.Data.Object is not Invoice { Subscription: not null } invoice) + { + return; + } + + var subscription = await dbContext + .Subscriptions + .SingleOrDefaultAsync( + subscription => subscription.StripeSubscriptionId == invoice.SubscriptionId, + cancellationToken: ct); + + if (subscription != null) + { + subscription.EndDate = DateTimeOffset.UtcNow; // Mark as expired or failed + await dbContext.SaveChangesAsync(ct); + } + } + + private async Task HandleTipPayment( + Session session, + CancellationToken ct) + { + // Record the Tip + var tip = new Tip + { + Id = Guid.NewGuid(), + CreatedAt = DateTimeOffset.UtcNow, + StripeSessionId = session.Id, + TipperId = Guid.Parse(session.Metadata["tipperId"]), + TipperName = session.Metadata["tipperName"], + CreatorId = Guid.Parse(session.Metadata["creatorId"]), + CreatorName = session.Metadata["creatorName"], + Amount = session.AmountTotal!.Value / 100m, + Currency = session.Currency, + Message = session.Metadata["message"] + }; + + dbContext.Tips.Add(tip); + + // Record the Transaction + var transaction = new Transaction + { + Id = Guid.NewGuid(), + CreatedAt = DateTimeOffset.UtcNow, + Amount = tip.Amount, + Currency = tip.Currency, + Type = "Tip", + Timestamp = DateTime.UtcNow, + // TODO: __StripeInvoiceUrl = session.Invoice.HostedInvoiceUrl__ How come nor Invoice or InvoiceId are set. + }; + + dbContext.Transactions.Add(transaction); + + // Link the Transaction to the Tip + tip.TransactionId = transaction.Id; + + // Save the changes + await dbContext.SaveChangesAsync(ct); + + // Notify the Creator + notificationService.NotifyCreator( + tip.CreatorId, + new TipPaid( + tip.CreatorId, + tip.CreatorName, + tip.Amount, + tip.Currency, + tip.Message) + ); + } + + private async Task HandleSubscriptionPayment( + Session session, + CancellationToken ct) + { + // Record the Subscription + var subscription = new Data.Subscription + { + Id = Guid.NewGuid(), + CreatedAt = DateTimeOffset.UtcNow, + UserId = Guid.Parse(session.Metadata["userId"]), + CreatorId = Guid.Parse(session.Metadata["creatorId"]), + TierId = Guid.Parse(session.Metadata["tierId"]), + StartDate = DateTimeOffset.UtcNow, + StripeSessionId = session.Id, + StripeSubscriptionId = session.SubscriptionId + }; + + dbContext.Subscriptions.Add(subscription); + + // Record the Transaction + var transaction = new Transaction + { + Id = Guid.NewGuid(), + CreatedAt = DateTimeOffset.UtcNow, + Amount = session.AmountTotal!.Value / 100m, // Convert amount from cents to dollars + Currency = session.Currency, + Type = "Subscription", + Timestamp = DateTime.UtcNow, + // TODO: __StripeInvoiceUrl = session.Invoice.HostedInvoiceUrl__ How come nor Invoice or InvoiceId are set. + }; + + dbContext.Transactions.Add(transaction); + + // Link the Transaction to the Subscription + subscription.Transactions.Add(transaction); + + // Save the changes + await dbContext.SaveChangesAsync(ct); + + // Notify the Creator + notificationService.NotifyCreator( + subscription.CreatorId, + new SubscriptionPaid( + subscription.CreatorId, + session.Metadata["creatorName"], + subscription.TierId.ToString(), + subscription.StartDate) + ); + } + + public async Task HandleCheckoutSessionCompleted( + Event stripeEvent, + CancellationToken ct = default) + { + if (stripeEvent.Data.Object is not Session session) + { + return; + } + + switch (session.Mode) + { + // Check if this is a one-time tip + case "payment" when session.PaymentIntentId != null: + await HandleTipPayment(session, ct); + break; + // Check if this is a subscription + case "subscription" when session.SubscriptionId != null: + await HandleSubscriptionPayment(session, ct); + break; + } + } + + public async Task HandleCustomerSubscriptionCreated( + Event stripeEvent, + CancellationToken ct) + { + if (stripeEvent.Data.Object is not Subscription stripeSubscription) + return; + + var subscription = await dbContext + .Subscriptions + .SingleOrDefaultAsync( + subscription => subscription.StripeSubscriptionId == stripeSubscription.Id, + cancellationToken: ct); + + if (subscription != null) + { + subscription.StartDate = stripeSubscription.CurrentPeriodStart; + subscription.EndDate = null; // Active subscription + + await dbContext.SaveChangesAsync(ct); + } + } + + public async Task HandleCustomerSubscriptionUpdated( + Event stripeEvent, + CancellationToken ct) + { + if (stripeEvent.Data.Object is Subscription stripeSubscription) + { + var subscription = await dbContext + .Subscriptions + .SingleOrDefaultAsync( + s => s.StripeSubscriptionId == stripeSubscription.Id, + cancellationToken: ct); + + if (subscription != null) + { + subscription.StartDate = stripeSubscription.CurrentPeriodStart; + subscription.EndDate = null; // Active subscription + + await dbContext.SaveChangesAsync(ct); + } + } + } + + public async Task HandleCustomerSubscriptionDeleted( + Event stripeEvent, + CancellationToken ct) + { + var subscription = stripeEvent.Data.Object as Subscription; + var existingSubscription = await dbContext + .Subscriptions + .FirstOrDefaultAsync(x => x.StripeSubscriptionId == subscription!.Id, ct); + + if (existingSubscription != null) + { + var today = DateTime.Today; + int lastDay = DateTime.DaysInMonth(today.Year, today.Month); + var lastDayOfMonth = new DateTime(today.Year, today.Month, lastDay); + existingSubscription.EndDate = new DateTimeOffset(lastDayOfMonth); + await dbContext.SaveChangesAsync(ct); + } + } +} diff --git a/backend/src/Web/Features/Messages/Data/Message.cs b/backend/src/Web/Features/Messages/Data/Message.cs new file mode 100644 index 0000000..fff397b --- /dev/null +++ b/backend/src/Web/Features/Messages/Data/Message.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Hutopy.Web.Features.Messages.Data; + +public class Message +{ + public Guid Id { get; set; } + public Guid SubjectId { get; set; } + public Guid CreatedBy { get; set; } + [MaxLength(255)] public required string CreatedByName { get; set; } + [MaxLength(255)] public string? CreatedByPortraitUrl { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public Guid? ParentId { get; set; } + [MaxLength(2048)] public required string Value { get; set; } +} diff --git a/backend/src/Web/Features/Messages/Data/MessagingDbContext.cs b/backend/src/Web/Features/Messages/Data/MessagingDbContext.cs new file mode 100644 index 0000000..5f19b86 --- /dev/null +++ b/backend/src/Web/Features/Messages/Data/MessagingDbContext.cs @@ -0,0 +1,76 @@ +using Hutopy.Web.Features.Messages.Handlers.Models; + +namespace Hutopy.Web.Features.Messages.Data; + +public class MessagingDbContext( + DbContextOptions options) + : DbContext(options) +{ + public const string SchemaName = "Messaging"; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder + .Entity() + .Property(c => c.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + } + + public DbSet Messages { get; set; } + + public async Task> GetMessagesAsync( + Guid subjectId, + Guid? parentId, + Guid? lastId, + int pageSize, + CancellationToken ct = default) + { + var query = Messages + .Where(c => c.SubjectId == subjectId) + .Where(c => c.ParentId == parentId); + + if (lastId.HasValue) + { + var lastMessage = await Messages + .Where(c => c.Id == lastId.Value) + .Select(c => new { c.CreatedAt, c.Id }) + .FirstOrDefaultAsync(cancellationToken: ct); + + if (lastMessage != null) + { + query = query + .Where(c => c.CreatedAt < lastMessage.CreatedAt + || (c.CreatedAt == lastMessage.CreatedAt && c.Id < lastMessage.Id)); + } + } + + var messages = await query + .OrderByDescending(c => c.CreatedAt) + .ThenByDescending(c => c.Id) + .Take(pageSize) + .Select(message => message.ToDto()) + .ToListAsync(cancellationToken: ct); + + return messages; + } + + public async Task GetMessageCountAsync( + Guid subjectId, + Guid? parentId, + int pageSize, + CancellationToken ct = default) + { + var query = Messages + .Where(c => c.SubjectId == subjectId) + .Where(c => c.ParentId == parentId); + + var messageCount = await query + .Take(pageSize) + .CountAsync(ct); + + return messageCount; + } +} diff --git a/backend/src/Web/Features/Messages/Data/MessagingDbContextInitializer.cs b/backend/src/Web/Features/Messages/Data/MessagingDbContextInitializer.cs new file mode 100644 index 0000000..cd40485 --- /dev/null +++ b/backend/src/Web/Features/Messages/Data/MessagingDbContextInitializer.cs @@ -0,0 +1,32 @@ +namespace Hutopy.Web.Features.Messages.Data; + +public static class InitializerExtensions +{ + public static async Task InitialiseMessagingDbContextAsync(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + + var initializer = scope.ServiceProvider.GetRequiredService(); + + await initializer.InitialiseAsync(); + } +} + +public class MessagingDbContextInitializer( + ILogger logger, + MessagingDbContext context + ) +{ + public async Task InitialiseAsync() + { + try + { + await context.Database.MigrateAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while initialising the messaging database."); + throw; + } + } +} diff --git a/backend/src/Web/Features/Messages/Data/Migrations/20240805012343_Initial.Designer.cs b/backend/src/Web/Features/Messages/Data/Migrations/20240805012343_Initial.Designer.cs new file mode 100644 index 0000000..31e6ba9 --- /dev/null +++ b/backend/src/Web/Features/Messages/Data/Migrations/20240805012343_Initial.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using Hutopy.Web.Features.Messages.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.Messages.Migrations +{ + [DbContext(typeof(MessagingDbContext))] + [Migration("20240805012343_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Messaging") + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", 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("CreatedByName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedByPortraitUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Messages", "Messaging"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Messages/Data/Migrations/20240805012343_Initial.cs b/backend/src/Web/Features/Messages/Data/Migrations/20240805012343_Initial.cs new file mode 100644 index 0000000..ce0f4cd --- /dev/null +++ b/backend/src/Web/Features/Messages/Data/Migrations/20240805012343_Initial.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Messages.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "Messaging"); + + migrationBuilder.CreateTable( + name: "Messages", + schema: "Messaging", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SubjectId = table.Column(type: "uuid", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + CreatedByName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + CreatedByPortraitUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + ParentId = table.Column(type: "uuid", nullable: true), + Value = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Messages", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Messages", + schema: "Messaging"); + } + } +} diff --git a/backend/src/Web/Features/Messages/Data/Migrations/20241217225954_ChangeStripeId.Designer.cs b/backend/src/Web/Features/Messages/Data/Migrations/20241217225954_ChangeStripeId.Designer.cs new file mode 100644 index 0000000..ed928e5 --- /dev/null +++ b/backend/src/Web/Features/Messages/Data/Migrations/20241217225954_ChangeStripeId.Designer.cs @@ -0,0 +1,70 @@ +// +using System; +using Hutopy.Web.Features.Messages.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.Messages.Migrations +{ + [DbContext(typeof(MessagingDbContext))] + [Migration("20241217225954_ChangeStripeId")] + partial class ChangeStripeId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Messaging") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", 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("CreatedByName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedByPortraitUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.ToTable("Messages", "Messaging"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Messages/Data/Migrations/20241217225954_ChangeStripeId.cs b/backend/src/Web/Features/Messages/Data/Migrations/20241217225954_ChangeStripeId.cs new file mode 100644 index 0000000..44a9df9 --- /dev/null +++ b/backend/src/Web/Features/Messages/Data/Migrations/20241217225954_ChangeStripeId.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Messages.Migrations +{ + /// + public partial class ChangeStripeId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Value", + schema: "Messaging", + table: "Messages", + type: "character varying(2048)", + maxLength: 2048, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Value", + schema: "Messaging", + table: "Messages", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(2048)", + oldMaxLength: 2048); + } + } +} diff --git a/backend/src/Web/Features/Messages/Data/Migrations/MessagingDbContextModelSnapshot.cs b/backend/src/Web/Features/Messages/Data/Migrations/MessagingDbContextModelSnapshot.cs new file mode 100644 index 0000000..14c77a2 --- /dev/null +++ b/backend/src/Web/Features/Messages/Data/Migrations/MessagingDbContextModelSnapshot.cs @@ -0,0 +1,67 @@ +// +using System; +using Hutopy.Web.Features.Messages.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hutopy.Web.Features.Messages.Migrations +{ + [DbContext(typeof(MessagingDbContext))] + partial class MessagingDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Messaging") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Messages.Data.Message", 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("CreatedByName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedByPortraitUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.ToTable("Messages", "Messaging"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Messages/DependencyInjection.cs b/backend/src/Web/Features/Messages/DependencyInjection.cs new file mode 100644 index 0000000..f75c425 --- /dev/null +++ b/backend/src/Web/Features/Messages/DependencyInjection.cs @@ -0,0 +1,16 @@ +using Hutopy.Web.Features.Messages.Data; + +namespace Hutopy.Web.Features.Messages; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddMessagingModule( + this WebApplicationBuilder builder, + Action? configureAction = null) + { + builder.Services.AddDbContext(configureAction); + builder.Services.AddScoped(); + + return builder; + } +} diff --git a/backend/src/Web/Features/Messages/Handlers/AddMessage.cs b/backend/src/Web/Features/Messages/Handlers/AddMessage.cs new file mode 100644 index 0000000..e2f8f5b --- /dev/null +++ b/backend/src/Web/Features/Messages/Handlers/AddMessage.cs @@ -0,0 +1,57 @@ +using Hutopy.Web.Common; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Messages.Data; + +namespace Hutopy.Web.Features.Messages.Handlers; + +public sealed class AddMessageRequest +{ + public Guid? Id { get; set; } + public required Guid SubjectId { get; set; } + public required string Message { get; set; } +} + +internal sealed class AddMessageRequestValidator + : Validator +{ + public AddMessageRequestValidator() + { + RuleFor(r => r.SubjectId) + .NotNull().WithMessage("You must specify a SubjectId") + .NotEmpty().WithMessage("You must specify a non-empty SubjectId"); + + RuleFor(r => r.Message) + .NotNull().WithMessage("You must specify a Message") + .NotEmpty().WithMessage("You must specify a non-empty Message"); + } +} + +public class AddMessage( + MessagingDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/messages"); + Options(o => o.WithTags("Messages")); + } + + public override async Task HandleAsync( + AddMessageRequest req, + CancellationToken ct) + { + var message = new Message + { + Id = req.Id ?? GuidHelper.GenerateUuidV7(), + SubjectId = req.SubjectId, + CreatedBy = User.GetUserId(), + CreatedByName = User.GetAlias() ?? $"{User.GetFirstName()} {User.GetLastName()}", + CreatedByPortraitUrl = User.GetPortraitUrl(), + Value = req.Message + }; + + await context.Messages.AddAsync(message, ct); + + await context.SaveChangesAsync(ct); + } +} diff --git a/backend/src/Web/Features/Messages/Handlers/AddReply.cs b/backend/src/Web/Features/Messages/Handlers/AddReply.cs new file mode 100644 index 0000000..04f086a --- /dev/null +++ b/backend/src/Web/Features/Messages/Handlers/AddReply.cs @@ -0,0 +1,62 @@ +using Hutopy.Web.Common; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Messages.Data; + +namespace Hutopy.Web.Features.Messages.Handlers; + +public sealed class AddReplyRequest +{ + public Guid? Id { get; set; } + public required Guid ParentId { get; set; } + public required Guid SubjectId { get; set; } + public required string Message { get; set; } +} + +internal sealed class AddReplyRequestValidator + : Validator +{ + public AddReplyRequestValidator() + { + RuleFor(r => r.ParentId) + .NotNull().WithMessage("You must specify a ParentId") + .NotEmpty().WithMessage("You must specify a non-empty ParentId"); + + RuleFor(r => r.SubjectId) + .NotNull().WithMessage("You must specify a SubjectId") + .NotEmpty().WithMessage("You must specify a non-empty SubjectId"); + + RuleFor(r => r.Message) + .NotNull().WithMessage("You must specify a Message") + .NotEmpty().WithMessage("You must specify a non-empty Message"); + } +} + +internal sealed class AddReply( + MessagingDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/messages/{ParentId:guid}/replies"); + Options(o => o.WithTags("Messages")); + } + + public override async Task HandleAsync( + AddReplyRequest req, + CancellationToken ct) + { + var message = new Message + { + Id = GuidHelper.GenerateUuidV7(), + SubjectId = req.SubjectId, + ParentId = req.ParentId, + CreatedBy = User.GetUserId(), + CreatedByName = User.GetName(), + Value = req.Message + }; + + await context.Messages.AddAsync(message, ct); + + await context.SaveChangesAsync(ct); + } +} diff --git a/backend/src/Web/Features/Messages/Handlers/ChangeMessage.cs b/backend/src/Web/Features/Messages/Handlers/ChangeMessage.cs new file mode 100644 index 0000000..d151416 --- /dev/null +++ b/backend/src/Web/Features/Messages/Handlers/ChangeMessage.cs @@ -0,0 +1,65 @@ +using Hutopy.Web.Common; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Messages.Data; + +namespace Hutopy.Web.Features.Messages.Handlers; + +public sealed class ChangeMessageRequest +{ + public Guid? Id { get; set; } + public required Guid SubjectId { get; set; } + public required string Message { get; set; } +} + +internal sealed class ChangeMessageRequestValidator + : Validator +{ + public ChangeMessageRequestValidator() + { + RuleFor(r => r.SubjectId) + .NotNull().WithMessage("You must specify a SubjectId") + .NotEmpty().WithMessage("You must specify a non-empty SubjectId"); + + RuleFor(r => r.Message) + .NotNull().WithMessage("You must specify a Message") + .NotEmpty().WithMessage("You must specify a non-empty Message"); + } +} + +public class ChangeMessage( + MessagingDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/messages/update"); + Options(o => o.WithTags("Messages")); + } + + public override async Task HandleAsync( + ChangeMessageRequest req, + CancellationToken ct) + { + var message = await context.Messages.FirstOrDefaultAsync(x => x.Id == req.Id, ct); + + if (message is null) + { + await SendNotFoundAsync(ct); + return; + } + + var userId = HttpContext.User.GetUserId(); + if (message.CreatedBy != userId) + { + await SendForbiddenAsync(ct); + return; + } + + message.SubjectId = req.SubjectId; + message.Value = req.Message; + + context.Update(message); + + await context.SaveChangesAsync(ct); + } +} diff --git a/backend/src/Web/Features/Messages/Handlers/DeleteMessage.cs b/backend/src/Web/Features/Messages/Handlers/DeleteMessage.cs new file mode 100644 index 0000000..a95f8e5 --- /dev/null +++ b/backend/src/Web/Features/Messages/Handlers/DeleteMessage.cs @@ -0,0 +1,53 @@ +using Hutopy.Web.Common; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Messages.Data; + +namespace Hutopy.Web.Features.Messages.Handlers; + +public record DeleteMessageRequest(Guid MessageId); + +internal sealed class DeleteMessageRequestValidator + : Validator +{ + public DeleteMessageRequestValidator() + { + RuleFor(r => r.MessageId) + .NotNull().WithMessage("You must specify a MessageId") + .NotEmpty().WithMessage("You must specify a non-empty MessageId"); + } +} + +public class DeleteMessage( + MessagingDbContext context) + : Endpoint +{ + public override void Configure() + { + Delete("/api/messages/{MessageId}"); + Options(o => o.WithTags("Messages")); + } + + public override async Task HandleAsync( + DeleteMessageRequest req, + CancellationToken ct) + { + var message = await context.Messages.FirstOrDefaultAsync(x => x.Id == req.MessageId, ct); + + if (message is null) + { + await SendNotFoundAsync(ct); + return; + } + + var userId = HttpContext.User.GetUserId(); + if (message.CreatedBy != userId) + { + await SendForbiddenAsync(ct); + return; + } + + context.Messages.Remove(message); + + await context.SaveChangesAsync(ct); + } +} diff --git a/backend/src/Web/Features/Messages/Handlers/GetMessageCount.cs b/backend/src/Web/Features/Messages/Handlers/GetMessageCount.cs new file mode 100644 index 0000000..0aea77a --- /dev/null +++ b/backend/src/Web/Features/Messages/Handlers/GetMessageCount.cs @@ -0,0 +1,44 @@ +using Hutopy.Web.Features.Messages.Data; + +namespace Hutopy.Web.Features.Messages.Handlers; + +public sealed class GetMessageCountRequest +{ + public Guid SubjectId { get; set; } + [BindFrom("page_size")] public int PageSize { get; set; } = 1000; +} + +public record struct GetMessageCountResponse +{ + public required int Count { get; init; } +} + +public class GetMessageCount( + MessagingDbContext context) + : Endpoint +{ + public override void Configure() + { + Get("/api/message-count/{SubjectId:guid}"); + Options(o => o.WithTags("Messages")); + AllowAnonymous(); + } + + public override async Task HandleAsync( + GetMessageCountRequest req, + CancellationToken ct) + { + var messageCount = await context.GetMessageCountAsync( + req.SubjectId, + null, + req.PageSize, + ct); + + await SendAsync( + new() + { + Count = messageCount + }, + cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Messages/Handlers/GetMessages.cs b/backend/src/Web/Features/Messages/Handlers/GetMessages.cs new file mode 100644 index 0000000..1fb7236 --- /dev/null +++ b/backend/src/Web/Features/Messages/Handlers/GetMessages.cs @@ -0,0 +1,47 @@ +using Hutopy.Web.Features.Messages.Data; +using Hutopy.Web.Features.Messages.Handlers.Models; + +namespace Hutopy.Web.Features.Messages.Handlers; + +public sealed class GetMessagesRequest +{ + public Guid SubjectId { get; set; } + [BindFrom("page_size")] public int PageSize { get; set; } = 10; + [BindFrom("last_id")] public Guid? LastId { get; set; } +} + +public record struct GetMessagesResponse +{ + public required List Messages { get; init; } +} + +public class GetMessages( + MessagingDbContext context) + : Endpoint +{ + public override void Configure() + { + Get("/api/messages/{SubjectId:guid}"); + Options(o => o.WithTags("Messages")); + AllowAnonymous(); + } + + public override async Task HandleAsync( + GetMessagesRequest req, + CancellationToken ct) + { + var messages = await context.GetMessagesAsync( + req.SubjectId, + null, + req.LastId, + req.PageSize, + ct); + + await SendAsync( + new() + { + Messages = messages + }, + cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Messages/Handlers/GetMessagesByUser.cs b/backend/src/Web/Features/Messages/Handlers/GetMessagesByUser.cs new file mode 100644 index 0000000..bdd5105 --- /dev/null +++ b/backend/src/Web/Features/Messages/Handlers/GetMessagesByUser.cs @@ -0,0 +1,46 @@ +using Hutopy.Web.Features.Messages.Data; +using Hutopy.Web.Features.Messages.Handlers.Models; + +namespace Hutopy.Web.Features.Messages.Handlers; + +public class GetMessagesByUserRequest +{ + public Guid UserId { get; set; } +} + +public record struct GetMessagesByUserResponse +{ + public required List Messages { get; init; } +} + +public class GetMessagesByUser( + MessagingDbContext context) + : Endpoint +{ + public override void Configure() + { + Get("/api/messages/user/{UserId:guid}"); + Options(o => o.WithTags("Messages")); + AllowAnonymous(); + } + + public override async Task HandleAsync( + GetMessagesByUserRequest req, + CancellationToken ct) + { + var messages = await context + .Messages + .Where(c => c.CreatedBy == req.UserId) + .Where(c => c.ParentId == null) + .ToListAsync(cancellationToken: ct); + + await SendAsync( + new() + { + Messages = messages + .Select(message => message.ToDto()) + .ToList() + }, + cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Messages/Handlers/GetReplies.cs b/backend/src/Web/Features/Messages/Handlers/GetReplies.cs new file mode 100644 index 0000000..40b5f5f --- /dev/null +++ b/backend/src/Web/Features/Messages/Handlers/GetReplies.cs @@ -0,0 +1,48 @@ +using Hutopy.Web.Features.Messages.Data; +using Hutopy.Web.Features.Messages.Handlers.Models; + +namespace Hutopy.Web.Features.Messages.Handlers; + +public class GetRepliesRequest +{ + public Guid SubjectId { get; set; } + public Guid ParentId { get; set; } + [BindFrom("page_size")] public int PageSize { get; set; } = 10; + [BindFrom("last_id")] public Guid? LastId { get; set; } +} + +public record struct GetRepliesResponse +{ + public required List Messages { get; init; } +} + +public class GetReplies( + MessagingDbContext context) + : Endpoint +{ + public override void Configure() + { + Get("/api/messages/{ParentId:guid}/replies"); + Options(o => o.WithTags("Messages")); + AllowAnonymous(); + } + + public override async Task HandleAsync( + GetRepliesRequest req, + CancellationToken ct) + { + var replies = await context.GetMessagesAsync( + req.SubjectId, + req.ParentId, + req.LastId, + req.PageSize, + ct); + + await SendAsync( + new() + { + Messages = replies, + }, + cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Messages/Handlers/Models/MessageDto.cs b/backend/src/Web/Features/Messages/Handlers/Models/MessageDto.cs new file mode 100644 index 0000000..16b9e78 --- /dev/null +++ b/backend/src/Web/Features/Messages/Handlers/Models/MessageDto.cs @@ -0,0 +1,30 @@ +using Hutopy.Web.Features.Messages.Data; + +namespace Hutopy.Web.Features.Messages.Handlers.Models; + +public record struct MessageDto( + Guid Id, + Guid SubjectId, + Guid CreatedBy, + string CreatedByName, + string? CreatedByPortraitUrl, + DateTimeOffset CreatedAt, + Guid? ParentId, + string Value +); + +public static class MessageExtensions +{ + public static MessageDto ToDto(this Message message) => + new() + { + Id = message.Id, + ParentId = message.ParentId, + CreatedAt = message.CreatedAt, + CreatedBy = message.CreatedBy, + CreatedByName = message.CreatedByName, + CreatedByPortraitUrl = message.CreatedByPortraitUrl, + SubjectId = message.SubjectId, + Value = message.Value + }; +} diff --git a/backend/src/Web/Features/Users/Data/IdentityDbContext.cs b/backend/src/Web/Features/Users/Data/IdentityDbContext.cs new file mode 100644 index 0000000..c7a5d42 --- /dev/null +++ b/backend/src/Web/Features/Users/Data/IdentityDbContext.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; + +namespace Hutopy.Web.Features.Users.Data +{ + public class IdentityDbContext( + DbContextOptions options) + : IdentityDbContext(options) + { + public const string SchemaName = "Identity"; + protected override void OnModelCreating(ModelBuilder + modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.HasDefaultSchema(SchemaName); + } + + } +} diff --git a/backend/src/Web/Features/Users/Data/IdentityDbContextInitializer.cs b/backend/src/Web/Features/Users/Data/IdentityDbContextInitializer.cs new file mode 100644 index 0000000..5be1dcf --- /dev/null +++ b/backend/src/Web/Features/Users/Data/IdentityDbContextInitializer.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Identity; + +namespace Hutopy.Web.Features.Users.Data; + +public static class InitializerExtensions +{ + public static async Task InitialiseIdentityDatabaseAsync(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + + var initializer = scope.ServiceProvider.GetRequiredService(); + + await initializer.InitialiseAsync(); + + await initializer.SeedAsync(); + } +} + +public class IdentityDbContextInitializer( + ILogger logger, + IdentityDbContext context, + RoleManager roleManager) +{ + public async Task InitialiseAsync() + { + try + { + await context.Database.MigrateAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while initialising the database."); + throw; + } + } + + public async Task SeedAsync() + { + try + { + await TrySeedAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while seeding the database."); + throw; + } + } + + private async Task TrySeedAsync() + { + var administratorRole = new IdentityRole(KnownRoles.Administrator); + if (roleManager.Roles.All(r => r.Name != administratorRole.Name)) + { + await roleManager.CreateAsync(administratorRole); + } + + var roleCreator = new IdentityRole(KnownRoles.Creator); + if (roleManager.Roles.All(r => r.Name != roleCreator.Name)) + { + await roleManager.CreateAsync(roleCreator); + } + } +} diff --git a/backend/src/Web/Features/Users/Data/Migrations/20241020183421_Initial.Designer.cs b/backend/src/Web/Features/Users/Data/Migrations/20241020183421_Initial.Designer.cs new file mode 100644 index 0000000..1770d40 --- /dev/null +++ b/backend/src/Web/Features/Users/Data/Migrations/20241020183421_Initial.Designer.cs @@ -0,0 +1,304 @@ +// +using System; +using Hutopy.Web.Features.Users.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.Users.Data.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20241020183421_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Identity") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Users.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "Identity"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Users.IdentityUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Address") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Alias") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("Firstname") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("GoogleId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Lastname") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PortraitUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Hutopy.Web.Features.Users.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Hutopy.Web.Features.Users.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Users/Data/Migrations/20241020183421_Initial.cs b/backend/src/Web/Features/Users/Data/Migrations/20241020183421_Initial.cs new file mode 100644 index 0000000..76dd942 --- /dev/null +++ b/backend/src/Web/Features/Users/Data/Migrations/20241020183421_Initial.cs @@ -0,0 +1,260 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hutopy.Web.Features.Users.Data.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "Identity"); + + migrationBuilder.CreateTable( + name: "AspNetRoles", + schema: "Identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + schema: "Identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Alias = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + Firstname = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + Lastname = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + BirthDate = table.Column(type: "timestamp with time zone", nullable: true), + Address = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + PortraitUrl = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + GoogleId = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + schema: "Identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalSchema: "Identity", + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + schema: "Identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "Identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + schema: "Identity", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "Identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + schema: "Identity", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalSchema: "Identity", + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "Identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + schema: "Identity", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "Identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + schema: "Identity", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + schema: "Identity", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + schema: "Identity", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + schema: "Identity", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + schema: "Identity", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "Identity", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "Identity", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "AspNetRoles", + schema: "Identity"); + + migrationBuilder.DropTable( + name: "AspNetUsers", + schema: "Identity"); + } + } +} diff --git a/backend/src/Web/Features/Users/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/backend/src/Web/Features/Users/Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..0e94432 --- /dev/null +++ b/backend/src/Web/Features/Users/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,301 @@ +// +using System; +using Hutopy.Web.Features.Users.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hutopy.Web.Features.Users.Data.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Identity") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Users.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "Identity"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Users.IdentityUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Address") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Alias") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("Firstname") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("GoogleId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Lastname") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PortraitUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Hutopy.Web.Features.Users.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Hutopy.Web.Features.Users.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Hutopy.Web.Features.Users.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Web/Features/Users/DependencyInjection.cs b/backend/src/Web/Features/Users/DependencyInjection.cs new file mode 100644 index 0000000..0ac396f --- /dev/null +++ b/backend/src/Web/Features/Users/DependencyInjection.cs @@ -0,0 +1,43 @@ +using Hutopy.Web.Common.BlobStorage; +using Hutopy.Web.Features.Messages.Data; +using Hutopy.Web.Features.Users.Data; +using Microsoft.AspNetCore.Identity; + +namespace Hutopy.Web.Features.Users; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddIdentityModule( + this WebApplicationBuilder builder, + Action? configureAction = null) + { + builder.Services.AddDbContext(configureAction); + builder.Services.AddScoped(); + + builder.Services.AddDbContext(configureAction); + builder.Services.AddScoped(); + + builder.Services.AddAuthentication() + .AddBearerToken(IdentityConstants.BearerScheme); + + builder.Services.AddAuthorizationBuilder(); + + builder.Services + .AddIdentityCore() + .AddUserManager() + .AddRoles() + .AddEntityFrameworkStores() + .AddApiEndpoints() + .AddSignInManager>() + .AddDefaultTokenProviders(); + + // Singleton services + builder.Services.AddSingleton(TimeProvider.System); + builder.Services.AddSingleton(); + + // Scoped services + builder.Services.AddScoped(); + + return builder; + } +} diff --git a/backend/src/Web/Features/Users/Handlers/ChangeAddress.cs b/backend/src/Web/Features/Users/Handlers/ChangeAddress.cs new file mode 100644 index 0000000..a8ebc1d --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/ChangeAddress.cs @@ -0,0 +1,41 @@ +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Users.Handlers; + +[PublicAPI] +public record ChangeAddressRequest( + string? Address); + +[PublicAPI] +public class ChangeAddressHandler( + IdentityUserManager userManager) + : Endpoint +{ + public override void Configure() + { + Post("/api/users/address"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + ChangeAddressRequest request, + CancellationToken ct) + { + var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString()); + + if (user is null) + { + await SendNotFoundAsync(ct); + return; + } + + user.Address = request.Address; + + var result = await userManager.UpdateAsync(user); + + if (result.Succeeded) + await SendOkAsync(ct); + else + await SendUnauthorizedAsync(ct); + } +} diff --git a/backend/src/Web/Features/Users/Handlers/ChangeAlias.cs b/backend/src/Web/Features/Users/Handlers/ChangeAlias.cs new file mode 100644 index 0000000..320a285 --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/ChangeAlias.cs @@ -0,0 +1,41 @@ +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Users.Handlers; + +[PublicAPI] +public record ChangeAliasRequest( + string? Alias); + +[PublicAPI] +public class ChangeAliasHandler( + IdentityUserManager userManager) + : Endpoint +{ + public override void Configure() + { + Post("/api/users/alias"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + ChangeAliasRequest request, + CancellationToken ct) + { + var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString()); + + if (user is null) + { + await SendNotFoundAsync(ct); + return; + } + + user.Alias = request.Alias; + + var result = await userManager.UpdateAsync(user); + + if (result.Succeeded) + await SendOkAsync(ct); + else + await SendUnauthorizedAsync(ct); + } +} diff --git a/backend/src/Web/Features/Users/Handlers/ChangeBirthDate.cs b/backend/src/Web/Features/Users/Handlers/ChangeBirthDate.cs new file mode 100644 index 0000000..4800321 --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/ChangeBirthDate.cs @@ -0,0 +1,41 @@ +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Users.Handlers; + +[PublicAPI] +public record ChangeBirthDateRequest( + DateTime BirthDate); + +[PublicAPI] +public class ChangeBirthDateHandler( + IdentityUserManager userManager) + : Endpoint +{ + public override void Configure() + { + Post("/api/users/birthdate"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + ChangeBirthDateRequest request, + CancellationToken ct) + { + var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString()); + + if (user is null) + { + await SendNotFoundAsync(ct); + return; + } + + user.BirthDate = request.BirthDate; + + var result = await userManager.UpdateAsync(user); + + if (result.Succeeded) + await SendOkAsync(ct); + else + await SendUnauthorizedAsync(ct); + } +} diff --git a/backend/src/Web/Features/Users/Handlers/ChangeEmail.cs b/backend/src/Web/Features/Users/Handlers/ChangeEmail.cs new file mode 100644 index 0000000..64b8286 --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/ChangeEmail.cs @@ -0,0 +1,42 @@ +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Users.Handlers; + +[PublicAPI] +public record ChangeEmailRequest( + string? Email); + +[PublicAPI] +public class ChangeEmailHandler( + IdentityUserManager userManager) + : Endpoint +{ + public override void Configure() + { + Post("/api/users/email"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + ChangeEmailRequest request, + CancellationToken ct) + { + var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString()); + + if (user is null) + { + await SendNotFoundAsync(ct); + return; + } + + user.Email = request.Email; + + // TODO: check to see if identity resets the `email confirmed` flag - @jonathan + var result = await userManager.UpdateAsync(user); + + if (result.Succeeded) + await SendOkAsync(ct); + else + await SendUnauthorizedAsync(ct); + } +} diff --git a/backend/src/Web/Features/Users/Handlers/ChangeFullname.cs b/backend/src/Web/Features/Users/Handlers/ChangeFullname.cs new file mode 100644 index 0000000..d130a0c --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/ChangeFullname.cs @@ -0,0 +1,43 @@ +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Users.Handlers; + +[PublicAPI] +public record ChangeFullnameRequest( + string? Firstname, + string? Lastname); + +[PublicAPI] +public class ChangeFullnameHandler( + IdentityUserManager userManager) + : Endpoint +{ + public override void Configure() + { + Post("/api/users/fullname"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + ChangeFullnameRequest request, + CancellationToken ct) + { + var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString()); + + if (user is null) + { + await SendNotFoundAsync(ct); + return; + } + + user.Firstname = request.Firstname; + user.Lastname = request.Lastname; + + var result = await userManager.UpdateAsync(user); + + if (result.Succeeded) + await SendOkAsync(ct); + else + await SendUnauthorizedAsync(ct); + } +} diff --git a/backend/src/Web/Features/Users/Handlers/ChangePhone.cs b/backend/src/Web/Features/Users/Handlers/ChangePhone.cs new file mode 100644 index 0000000..e13d1d2 --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/ChangePhone.cs @@ -0,0 +1,42 @@ +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Users.Handlers; + +[PublicAPI] +public record ChangePhoneRequest( + string? PhoneNumber); + +[PublicAPI] +public class ChangePhoneHandler( + IdentityUserManager userManager) + : Endpoint +{ + public override void Configure() + { + Post("/api/users/phone"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + ChangePhoneRequest request, + CancellationToken ct) + { + var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString()); + + if (user is null) + { + await SendNotFoundAsync(ct); + return; + } + + user.PhoneNumber = request.PhoneNumber; + // TODO: check to see if identity resets the `phone confirmed` flag - @jonathan + + var result = await userManager.UpdateAsync(user); + + if (result.Succeeded) + await SendOkAsync(ct); + else + await SendUnauthorizedAsync(ct); + } +} diff --git a/backend/src/Web/Features/Users/Handlers/ChangePortrait.cs b/backend/src/Web/Features/Users/Handlers/ChangePortrait.cs new file mode 100644 index 0000000..31b90d1 --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/ChangePortrait.cs @@ -0,0 +1,72 @@ +using Hutopy.Web.Common.BlobStorage; +using Hutopy.Web.Common.Security; + +namespace Hutopy.Web.Features.Users.Handlers; + +[PublicAPI] +public record ChangePortraitRequest( + IFormFile File); + +[PublicAPI] +public record ChangePortraitResponse( + string BlobUrl); + +[PublicAPI] +public sealed class ChangePortraitRequestValidator : Validator +{ + public ChangePortraitRequestValidator() + { + RuleFor(x => x.File) + .NotNull() + .NotEmpty(); + } +} + +[PublicAPI] +public class ChangePortraitHandler( + IdentityUserManager userManager, + AzureBlobStorage blobStorage) + : Endpoint +{ + public override void Configure() + { + Post("/api/users/portrait"); + Options(o => o.WithTags("Users")); + AllowFileUploads(); + } + + public override async Task HandleAsync( + ChangePortraitRequest request, + CancellationToken ct) + { + var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString()); + + if (user is null) + { + await SendNotFoundAsync(ct); + return; + } + + var blobUrl = await blobStorage.UploadFileAsync( + ContainerNames.Users, + $"{user.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}", + request.File.OpenReadStream(), + request.File.ContentType, + ct); + + user.PortraitUrl = blobUrl; + + var result = await userManager.UpdateAsync(user); + + if (result.Succeeded) + { + await SendOkAsync( + new ChangePortraitResponse(blobUrl), + ct); + } + else + { + await SendUnauthorizedAsync(ct); + } + } +} diff --git a/backend/src/Web/Features/Users/Handlers/GetCurrentUser.cs b/backend/src/Web/Features/Users/Handlers/GetCurrentUser.cs new file mode 100644 index 0000000..9bef3a2 --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/GetCurrentUser.cs @@ -0,0 +1,56 @@ +using Hutopy.Web.Features.Users.Handlers.Models; +using Hutopy.Web.Features.Memberships.Data; +using Hutopy.Web.Features.Memberships.Infrastructure; + +namespace Hutopy.Web.Features.Users.Handlers; + +[PublicAPI] +public class GetCurrentUserQueryHandler( + IdentityService identityService, + MembershipDbContext membershipDbContext) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/users/profile"); + Options(o => o.WithTags("Memberships")); + } + + public override async Task HandleAsync( + CancellationToken cancellationToken) + { + var userModel = await identityService.GetCurrentUserAsync(); + + if (userModel is null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + var roles = await identityService.GetCurrentUserRolesAsync(); + + var stripeId = await membershipDbContext + .Creators + .Where(c => c.Id == userModel.Id) + .Select(c => c.StripeAccountId) + .FirstOrDefaultAsync(cancellationToken); + + await SendOkAsync( + new UserDto + { + Id = userModel.Id, + Alias = userModel.Alias, + PortraitUrl = userModel.PortraitUrl, + Firstname = userModel.Firstname, + Lastname = userModel.Lastname, + Username = userModel.Username, + PhoneNumber = userModel.PhoneNumber, + Email = userModel.Email, + BirthDate = userModel.BirthDate, + Address = userModel.Address, + UserRoles = roles, + StripeId = stripeId ?? string.Empty + }, + cancellationToken); + } +} diff --git a/backend/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs b/backend/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs new file mode 100644 index 0000000..3ffee88 --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/GetCurrentUserProfilePicture.cs @@ -0,0 +1,30 @@ +using Hutopy.Web.Common.BlobStorage; + +namespace Hutopy.Web.Features.Users.Handlers; + +[PublicAPI] +public class GetCurrentUserPortraitHandler( + IdentityService identityService, + AzureBlobStorage blobStorage +) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/users/portrait"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + CancellationToken cancellationToken) + { + var identityUser = await identityService.GetCurrentUserAsync(); + + var stream = await blobStorage.DownloadFileAsync( + ContainerNames.Users, + $"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}", + cancellationToken); + + await SendOkAsync(stream, cancellationToken); + } +} diff --git a/backend/src/Web/Features/Users/Handlers/LoginWithGoogle.cs b/backend/src/Web/Features/Users/Handlers/LoginWithGoogle.cs new file mode 100644 index 0000000..dfd389c --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/LoginWithGoogle.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Hutopy.Web.Common.Security; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace Hutopy.Web.Features.Users.Handlers; + +class GoogleToken +{ + [JsonPropertyName("access_token")] public required string AccessToken { get; init; } + [JsonPropertyName("token_type")] public required string TokenType { get; init; } + [JsonPropertyName("expires_in")] public required int ExpiresIn { get; init; } + [JsonPropertyName("scope")] public required string Scope { get; init; } + [JsonPropertyName("authuser")] public required string AuthUser { get; init; } + [JsonPropertyName("prompt")] public required string Prompt { get; init; } +} + +public class GoogleUserInfo +{ + [JsonPropertyName("id")] public required string Id { get; init; } + [JsonPropertyName("email")] public required string Email { get; init; } + [JsonPropertyName("verified_email")] public required bool VerifiedEmail { get; init; } + [JsonPropertyName("name")] public required string Name { get; init; } + [JsonPropertyName("given_name")] public required string GivenName { get; init; } + [JsonPropertyName("family_name")] public string FamilyName { get; init; } = string.Empty; + [JsonPropertyName("picture")] public required string Picture { get; init; } +} + +[PublicAPI] +public record LoginWithGoogleRequest( + string Token); + +[PublicAPI] +public record LoginWithGoogleResponse( + string AccessToken, + string RefreshToken); + +[PublicAPI] +public class LoginWithGoogleHandler( + IHttpClientFactory httpClientFactory, + IdentityUserManager userManager, + SignInManager signInManager, + IOptionsSnapshot jwtOptions) + : Endpoint +{ + public override void Configure() + { + AllowAnonymous(); + Post("/api/users/login-with-google"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + LoginWithGoogleRequest request, + CancellationToken ct) + { + var googleToken = JsonSerializer.Deserialize(request.Token)!; + + // Verify the token with Google + using var httpClient = httpClientFactory.CreateClient(); + using var response = await httpClient.GetAsync( + $"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}", + ct); + if (!response.IsSuccessStatusCode) + { + await SendStringAsync( + "The token is not valid", + 400, + cancellation: ct); + return; + } + + // Extract the user info (email, name, etc.). + var content = await response.Content.ReadAsStringAsync(ct); + var userInfo = JsonSerializer.Deserialize(content); + if (userInfo is null + || !userInfo.VerifiedEmail + || string.IsNullOrEmpty(userInfo.Email)) + { + await SendStringAsync( + "The token does not contain an email", + 400, + cancellation: ct); + return; + } + + // Check if user exists or create a new one + var user = await userManager.FindByEmailAsync(userInfo.Email); + + if (user is null) + { + var generatedPassword = PasswordGenerator.GeneratePassword(10, 12); + var generatedUser = new IdentityUser + { + UserName = userInfo.Email, + Email = userInfo.Email, + Firstname = userInfo.GivenName, + Lastname = userInfo.FamilyName, + Alias = userInfo.Name, + PortraitUrl = userInfo.Picture, + GoogleId = userInfo.Id, + }; + + var result = await userManager.CreateAsync( + generatedUser, + generatedPassword); + + if (!result.Succeeded) + { + await SendStringAsync( + result.Errors.First().Description, + 400, + cancellation: ct); + return; + } + + user = generatedUser; + } + + await signInManager.SignInAsync(user, isPersistent: false); + + var accessToken = JwtTokenHelper.GenerateJwtToken( + expiresIn: jwtOptions.Value.Lifetime, + issuer: jwtOptions.Value.Issuer, + audience: jwtOptions.Value.Audience, + key: jwtOptions.Value.Key, + userId: user.Id.ToString(), + email: user.Email, + alias: user.Alias, + firstname: user.Firstname, + lastname: user.Lastname, + portraitUrl: user.PortraitUrl); + + await SendOkAsync( + new LoginWithGoogleResponse(accessToken, string.Empty), + cancellation: ct); + } +} diff --git a/backend/src/Web/Features/Users/Handlers/Models/UserDto.cs b/backend/src/Web/Features/Users/Handlers/Models/UserDto.cs new file mode 100644 index 0000000..3295cac --- /dev/null +++ b/backend/src/Web/Features/Users/Handlers/Models/UserDto.cs @@ -0,0 +1,17 @@ +namespace Hutopy.Web.Features.Users.Handlers.Models; + +public class UserDto +{ + public Guid Id { get; init; } + public IList UserRoles { get; init; } = []; + public string Username { get; init; } = null!; + public string? Alias { get; init; } + public string? PortraitUrl { get; init; } + public string? Firstname { get; init; } + public string? Lastname { get; init; } + public string? Email { get; init; } + public string? PhoneNumber { get; init; } + public DateTime? BirthDate { get; init; } + public string? Address { get; init; } + public string? StripeId { get; init; } +} diff --git a/backend/src/Web/Features/Users/IdentityResultExtensions.cs b/backend/src/Web/Features/Users/IdentityResultExtensions.cs new file mode 100644 index 0000000..a9cad9f --- /dev/null +++ b/backend/src/Web/Features/Users/IdentityResultExtensions.cs @@ -0,0 +1,14 @@ +using Hutopy.Web.Features.Users.Models; +using Microsoft.AspNetCore.Identity; + +namespace Hutopy.Web.Features.Users; + +public static class IdentityResultExtensions +{ + public static Result ToApplicationResult(this IdentityResult result) + { + return result.Succeeded + ? Result.Success() + : Result.Failure(result.Errors.Select(e => e.Description)); + } +} diff --git a/backend/src/Web/Features/Users/IdentityRole.cs b/backend/src/Web/Features/Users/IdentityRole.cs new file mode 100644 index 0000000..d5e68f1 --- /dev/null +++ b/backend/src/Web/Features/Users/IdentityRole.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Identity; + +namespace Hutopy.Web.Features.Users; + +public class IdentityRole : IdentityRole +{ + public IdentityRole() { } + public IdentityRole(string roleName) : base(roleName) { } +} diff --git a/backend/src/Web/Features/Users/IdentityService.cs b/backend/src/Web/Features/Users/IdentityService.cs new file mode 100644 index 0000000..cc8e21b --- /dev/null +++ b/backend/src/Web/Features/Users/IdentityService.cs @@ -0,0 +1,94 @@ +using System.Security.Claims; +using Hutopy.Web.Features.Users.Models; + +namespace Hutopy.Web.Features.Users; + +public class IdentityService( + IdentityUserManager userManager, + IHttpContextAccessor contextAccessor +) +{ + public async Task> CreateUserAsync(string email, string userName, string firstName, string lastName, + string password) + { + var applicationUser = new IdentityUser + { + UserName = userName, Email = email, Firstname = firstName, Lastname = lastName + }; + + var response = await userManager.CreateAsync(applicationUser, password); + + if (!response.Succeeded) + { + throw new BadHttpRequestException(response.Errors.First().Description); + } + + var result = new Result(applicationUser.Id, response.Succeeded, response.ToApplicationResult().Errors); + + return result; + } + + private static UserModel BuildModelFrom(IdentityUser response) + { + var userModel = new UserModel + { + Id = response.Id, + Username = response.UserName ?? string.Empty, + PhoneNumber = response.PhoneNumber ?? string.Empty, + Email = response.Email ?? string.Empty, + PortraitUrl = response.PortraitUrl, + Alias = response.Alias, + Firstname = response.Firstname, + Lastname = response.Lastname, + BirthDate = response.BirthDate, + Address = response.Address, + }; + + return userModel; + } + + public async Task FindUserByIdAsync(string id) + { + var user = await userManager.FindByIdAsync(id); + + if (user == null) return null; + + var userModel = BuildModelFrom(user); + + return userModel; + } + + public async Task FindUserByEmailAsync(string email) + { + var response = await userManager.FindByEmailAsync(email); + + if (response == null) return null; + + return BuildModelFrom(response); + } + + public async Task GetCurrentUserAsync() + { + var currentUserId = contextAccessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(currentUserId)) + { + return null; + } + + return await FindUserByIdAsync(currentUserId); + } + + public async Task> GetCurrentUserRolesAsync() + { + var currentUserModel = await GetCurrentUserAsync(); + + var currentUser = await userManager.FindByIdAsync(currentUserModel.Id.ToString()); + + if (currentUser is null) return []; + + var userRoles = await userManager.GetRolesAsync(currentUser); + + return userRoles; + } + +} diff --git a/backend/src/Web/Features/Users/IdentityUser.cs b/backend/src/Web/Features/Users/IdentityUser.cs new file mode 100644 index 0000000..e648674 --- /dev/null +++ b/backend/src/Web/Features/Users/IdentityUser.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Identity; + +namespace Hutopy.Web.Features.Users; + +public class IdentityUser : IdentityUser +{ + [MaxLength(255)] public string? Alias { get; set; } + [MaxLength(255)] public string? Firstname { get; set; } + [MaxLength(255)] public string? Lastname { get; set; } + public DateTime? BirthDate { get; set; } + [MaxLength(255)] public string? Address { get; set; } + [MaxLength(255)] public string? PortraitUrl { get; set; } + [MaxLength(255)] public string? GoogleId { get; set; } +} diff --git a/backend/src/Web/Features/Users/IdentityUserManager.cs b/backend/src/Web/Features/Users/IdentityUserManager.cs new file mode 100644 index 0000000..e74ed46 --- /dev/null +++ b/backend/src/Web/Features/Users/IdentityUserManager.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace Hutopy.Web.Features.Users; + +public sealed class IdentityUserManager( + IUserStore store, + IOptions optionsAccessor, + IPasswordHasher passwordHasher, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + IServiceProvider services, + ILogger> logger) + : UserManager( + store, + optionsAccessor, + passwordHasher, + userValidators, + passwordValidators, + keyNormalizer, + errors, + services, + logger) +{ +} diff --git a/backend/src/Web/Features/Users/JwtOptions.cs b/backend/src/Web/Features/Users/JwtOptions.cs new file mode 100644 index 0000000..7ee27a2 --- /dev/null +++ b/backend/src/Web/Features/Users/JwtOptions.cs @@ -0,0 +1,11 @@ +namespace Hutopy.Web.Features.Users; + +public record JwtOptions +{ + public const string SectionName = "Authentication:Jwt"; + + public required TimeSpan Lifetime { get; init; } + public required string Issuer { get; init; } + public required string Audience { get; init; } + public required string Key { get; init; } +} diff --git a/backend/src/Web/Features/Users/KnownRoles.cs b/backend/src/Web/Features/Users/KnownRoles.cs new file mode 100644 index 0000000..20c9ce2 --- /dev/null +++ b/backend/src/Web/Features/Users/KnownRoles.cs @@ -0,0 +1,7 @@ +namespace Hutopy.Web.Features.Users; + +public static class KnownRoles +{ + public const string Administrator = nameof(Administrator); + public const string Creator = nameof(Creator); +} diff --git a/backend/src/Web/Features/Users/Models/Result.cs b/backend/src/Web/Features/Users/Models/Result.cs new file mode 100644 index 0000000..8e34553 --- /dev/null +++ b/backend/src/Web/Features/Users/Models/Result.cs @@ -0,0 +1,49 @@ +namespace Hutopy.Web.Features.Users.Models; + +public class Result( + bool succeeded, + IEnumerable errors) +{ + public bool Succeeded { get; init; } = succeeded; + public string[] Errors { get; init; } = errors.ToArray(); + + public static Result Success() + { + return new Result(true, Array.Empty()); + } + + public static Result Failure(IEnumerable errors) + { + return new Result(false, errors); + } +} + +public class Result( + T? value, + bool succeeded, + IEnumerable errors) +{ + public bool Succeeded { get; init; } = succeeded; + public string[] Errors { get; init; } = errors.ToArray(); + public T? Value { get; set; } = value; + + public T GetValueOrDefault() + { + return Value ?? default(T)!; + } + + public string GetErrorsAsString() + { + return Errors.Length == 0 ? string.Empty : string.Join(", ", Errors); + } + + public static Result Success(T value) + { + return new Result(value, true, Array.Empty()); + } + + public static Result Failure(T value, IEnumerable errors) + { + return new Result(value, false, errors); + } +} diff --git a/backend/src/Web/Features/Users/Models/RoleModel.cs b/backend/src/Web/Features/Users/Models/RoleModel.cs new file mode 100644 index 0000000..a6243e1 --- /dev/null +++ b/backend/src/Web/Features/Users/Models/RoleModel.cs @@ -0,0 +1,7 @@ +namespace Hutopy.Web.Features.Users.Models; + +public class RoleModel +{ + public Guid Id { get; set; } + public string? Name { get; set; } +} diff --git a/backend/src/Web/Features/Users/Models/UserModel.cs b/backend/src/Web/Features/Users/Models/UserModel.cs new file mode 100644 index 0000000..4172360 --- /dev/null +++ b/backend/src/Web/Features/Users/Models/UserModel.cs @@ -0,0 +1,15 @@ +namespace Hutopy.Web.Features.Users.Models; + +public class UserModel +{ + public Guid Id { get; set; } + public string Username { get; init; } = null!; + public string? Alias { get; init; } + public string? PortraitUrl { get; init; } + public string? Firstname { get; init; } + public string? Lastname { get; init; } + public string? Email { get; init; } + public string? PhoneNumber { get; init; } + public DateTime? BirthDate { get; init; } + public string? Address { get; init; } +} diff --git a/backend/src/Web/GlobalUsings.cs b/backend/src/Web/GlobalUsings.cs new file mode 100644 index 0000000..4b94f25 --- /dev/null +++ b/backend/src/Web/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using FastEndpoints; +global using FluentValidation; +global using JetBrains.Annotations; +global using Microsoft.EntityFrameworkCore; diff --git a/backend/src/Web/Pages/Error.cshtml b/backend/src/Web/Pages/Error.cshtml new file mode 100644 index 0000000..6f92b95 --- /dev/null +++ b/backend/src/Web/Pages/Error.cshtml @@ -0,0 +1,26 @@ +@page +@model ErrorModel +@{ + ViewData["Title"] = "Error"; +} + +

Error.

+

An error occurred while processing your request.

+ +@if (Model.ShowRequestId) +{ +

+ Request ID: @Model.RequestId +

+} + +

Development Mode

+

+ Swapping to the Development environment displays detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

diff --git a/backend/src/Web/Pages/Error.cshtml.cs b/backend/src/Web/Pages/Error.cshtml.cs new file mode 100644 index 0000000..ff5eba0 --- /dev/null +++ b/backend/src/Web/Pages/Error.cshtml.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Hutopy.Web.Pages; + +[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] +public class ErrorModel : PageModel +{ + private readonly ILogger _logger; + + public ErrorModel(ILogger logger) + { + _logger = logger; + } + + public string? RequestId { get; set; } + + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + public void OnGet() + { + RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; + } +} diff --git a/backend/src/Web/Pages/Shared/_LoginPartial.cshtml b/backend/src/Web/Pages/Shared/_LoginPartial.cshtml new file mode 100644 index 0000000..ab55a54 --- /dev/null +++ b/backend/src/Web/Pages/Shared/_LoginPartial.cshtml @@ -0,0 +1,37 @@ +@using Hutopy.Web.Features.Users +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using IdentityUser = Hutopy.Web.Features.Users.IdentityUser +@inject SignInManager SignInManager + +@{ + string? returnUrl = null; + var query = ViewContext.HttpContext.Request.Query; + if (query.ContainsKey("returnUrl")) + { + returnUrl = query["returnUrl"]; + } +} + + diff --git a/backend/src/Web/Pages/_ViewImports.cshtml b/backend/src/Web/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..315c2e2 --- /dev/null +++ b/backend/src/Web/Pages/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using Hutopy.Web +@namespace Hutopy.Web.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/backend/src/Web/Program.cs b/backend/src/Web/Program.cs new file mode 100644 index 0000000..1b93b9b --- /dev/null +++ b/backend/src/Web/Program.cs @@ -0,0 +1,135 @@ +using Hutopy.Web; +using Hutopy.Web.Features.Contents; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Memberships; +using Hutopy.Web.Features.Memberships.Data; +using Hutopy.Web.Features.Messages; +using Hutopy.Web.Features.Messages.Data; +using Hutopy.Web.Features.Users; +using Hutopy.Web.Features.Users.Data; +using Microsoft.AspNetCore.HttpOverrides; +using NSwag; +using NSwag.Generation.AspNetCore.Processors; +using NSwag.Generation.Processors.Security; + +var builder = WebApplication.CreateBuilder(args); + +if (!builder.Environment.IsDevelopment()) +{ + var keyVaultEndpoint = new Uri(Environment.GetEnvironmentVariable("VaultUri") ?? ""); + builder.Configuration.AddAzureKeyVault(keyVaultEndpoint, new DefaultAzureCredential()); +} + +builder.Services.AddCors(options => +{ + options.AddPolicy( + "AllowAll", + policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +// Add services to the container. +builder.Services.AddKeyVaultIfConfigured(builder.Configuration); +builder.Services.AddWebServices(); +builder.Services.AddAuthorizationAndAuthentication(builder.Configuration); + +// TODO: This old tech should be remove - need to move Facebook / Google controllers to FastEndpoints +builder.Services.AddControllers(); + +builder.Services.AddOpenApiDocument(( + configure, + sp) => +{ + configure.Title = "Hutopy API"; + + // Add JWT + configure.AddSecurity( + "JWT", + [], + new OpenApiSecurityScheme + { + Type = OpenApiSecuritySchemeType.ApiKey, + Name = "Authorization", + In = OpenApiSecurityApiKeyLocation.Header, + Description = "Type into the textbox: Bearer {your JWT token}.", + }); + + configure.OperationProcessors.Add(new AspNetCoreOperationTagsProcessor()); + configure.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("JWT")); +}); + +var postgresConnectionString = builder.Configuration.GetConnectionString("PostgresConnection") + ?? throw new InvalidOperationException("Missing ConnectionStrings:PostgresConnection"); + +builder.Services.AddFastEndpoints(); +builder.AddIdentityModule(options => + options.UseNpgsql( + postgresConnectionString, + o => o.MigrationsHistoryTable("__EFMigrationsHistory", IdentityDbContext.SchemaName))); +builder.AddContentModule(options => + options.UseNpgsql( + postgresConnectionString, + o => o.MigrationsHistoryTable("__EFMigrationsHistory", ContentDbContext.SchemaName))); +builder.AddMessagingModule(options => + options.UseNpgsql( + postgresConnectionString, + o => o.MigrationsHistoryTable("__EFMigrationsHistory", MessagingDbContext.SchemaName))); +builder.AddMembershipModule( + options => options.UseNpgsql( + postgresConnectionString, + o => o.MigrationsHistoryTable("__EFMigrationsHistory", MembershipDbContext.SchemaName))); + +builder.Services.Configure(builder.Configuration.GetRequiredSection(JwtOptions.SectionName)); + +var app = builder.Build(); + +app.UseForwardedHeaders( + new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto } +); + +app.UseCors("AllowAll"); + +app.UseAuthentication(); +app.UseAuthorization(); + +// Initialize and seed the db. +await app.InitialiseIdentityDatabaseAsync(); +await app.InitialiseContentDbContextAsync(); +await app.InitialiseMessagingDbContextAsync(); +await app.InitialiseMembershipDbContextAsync(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHealthChecks("/health"); +app.UseHttpsRedirection(); +app.UseStaticFiles(); + +if (app.Environment.IsDevelopment()) +{ + app.UseOpenApi(); + app.UseSwaggerUi(options => options.Path = "/api"); +} + +app.MapControllerRoute( + name: "default", + pattern: "{controller}/{action=Index}/{id?}"); + +app.UseFastEndpoints(); + +app.Run(); + +namespace Hutopy.Web +{ + public abstract partial class Program + { + } +} diff --git a/backend/src/Web/Properties/ServiceDependencies/Hutopy - Zip Deploy/profile.arm.json b/backend/src/Web/Properties/ServiceDependencies/Hutopy - Zip Deploy/profile.arm.json new file mode 100644 index 0000000..277eb06 --- /dev/null +++ b/backend/src/Web/Properties/ServiceDependencies/Hutopy - Zip Deploy/profile.arm.json @@ -0,0 +1,173 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_dependencyType": "compute.function.linux.appService" + }, + "parameters": { + "resourceGroupName": { + "type": "string", + "defaultValue": "Hutopy", + "metadata": { + "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking." + } + }, + "resourceGroupLocation": { + "type": "string", + "defaultValue": "canadaeast", + "metadata": { + "description": "Location of the resource group. Resource groups could have different location than resources, however by default we use API versions from latest hybrid profile which support all locations for resource types we support." + } + }, + "resourceName": { + "type": "string", + "defaultValue": "Hutopy", + "metadata": { + "description": "Name of the main resource to be created by this template." + } + }, + "resourceLocation": { + "type": "string", + "defaultValue": "[parameters('resourceGroupLocation')]", + "metadata": { + "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there." + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/resourceGroups", + "name": "[parameters('resourceGroupName')]", + "location": "[parameters('resourceGroupLocation')]", + "apiVersion": "2019-10-01" + }, + { + "type": "Microsoft.Resources/deployments", + "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]", + "resourceGroup": "[parameters('resourceGroupName')]", + "apiVersion": "2019-10-01", + "dependsOn": [ + "[parameters('resourceGroupName')]" + ], + "properties": { + "mode": "Incremental", + "expressionEvaluationOptions": { + "scope": "inner" + }, + "parameters": { + "resourceGroupName": { + "value": "[parameters('resourceGroupName')]" + }, + "resourceGroupLocation": { + "value": "[parameters('resourceGroupLocation')]" + }, + "resourceName": { + "value": "[parameters('resourceName')]" + }, + "resourceLocation": { + "value": "[parameters('resourceLocation')]" + } + }, + "template": { + "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "type": "string" + }, + "resourceGroupLocation": { + "type": "string" + }, + "resourceName": { + "type": "string" + }, + "resourceLocation": { + "type": "string" + } + }, + "variables": { + "storage_name": "[toLower(concat('storage', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId))))]", + "appServicePlan_name": "[concat('Plan', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]", + "storage_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Storage/storageAccounts/', variables('storage_name'))]", + "appServicePlan_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/serverFarms/', variables('appServicePlan_name'))]", + "function_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/sites/', parameters('resourceName'))]" + }, + "resources": [ + { + "location": "[parameters('resourceLocation')]", + "name": "[parameters('resourceName')]", + "type": "Microsoft.Web/sites", + "apiVersion": "2015-08-01", + "tags": { + "[concat('hidden-related:', variables('appServicePlan_ResourceId'))]": "empty" + }, + "dependsOn": [ + "[variables('appServicePlan_ResourceId')]", + "[variables('storage_ResourceId')]" + ], + "kind": "functionapp", + "properties": { + "name": "[parameters('resourceName')]", + "kind": "functionapp", + "httpsOnly": true, + "reserved": false, + "serverFarmId": "[variables('appServicePlan_ResourceId')]", + "siteConfig": { + "alwaysOn": true, + "linuxFxVersion": "dotnet|3.1" + } + }, + "identity": { + "type": "SystemAssigned" + }, + "resources": [ + { + "name": "appsettings", + "type": "config", + "apiVersion": "2015-08-01", + "dependsOn": [ + "[variables('function_ResourceId')]" + ], + "properties": { + "AzureWebJobsStorage": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storage_name'), ';AccountKey=', listKeys(variables('storage_ResourceId'), '2017-10-01').keys[0].value, ';EndpointSuffix=', 'core.windows.net')]", + "FUNCTIONS_EXTENSION_VERSION": "~3", + "FUNCTIONS_WORKER_RUNTIME": "dotnet" + } + } + ] + }, + { + "location": "[parameters('resourceGroupLocation')]", + "name": "[variables('storage_name')]", + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2017-10-01", + "tags": { + "[concat('hidden-related:', concat('/providers/Microsoft.Web/sites/', parameters('resourceName')))]": "empty" + }, + "properties": { + "supportsHttpsTrafficOnly": true + }, + "sku": { + "name": "Standard_LRS" + }, + "kind": "Storage" + }, + { + "location": "[parameters('resourceGroupLocation')]", + "name": "[variables('appServicePlan_name')]", + "type": "Microsoft.Web/serverFarms", + "apiVersion": "2015-02-01", + "kind": "linux", + "properties": { + "name": "[variables('appServicePlan_name')]", + "sku": "Standard", + "workerSizeId": "0", + "reserved": true + } + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/backend/src/Web/Properties/ServiceDependencies/Hutopy - Zip Deploy/secrets1.arm.json b/backend/src/Web/Properties/ServiceDependencies/Hutopy - Zip Deploy/secrets1.arm.json new file mode 100644 index 0000000..3776ec4 --- /dev/null +++ b/backend/src/Web/Properties/ServiceDependencies/Hutopy - Zip Deploy/secrets1.arm.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "type": "string", + "defaultValue": "Hutopy", + "metadata": { + "_parameterType": "resourceGroup", + "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking." + } + }, + "resourceGroupLocation": { + "type": "string", + "defaultValue": "canadaeast", + "metadata": { + "_parameterType": "location", + "description": "Location of the resource group. Resource groups could have different location than resources." + } + }, + "resourceLocation": { + "type": "string", + "defaultValue": "[parameters('resourceGroupLocation')]", + "metadata": { + "_parameterType": "location", + "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there." + } + } + }, + "resources": [ + { + "type": "Microsoft.Resources/resourceGroups", + "name": "[parameters('resourceGroupName')]", + "location": "[parameters('resourceGroupLocation')]", + "apiVersion": "2019-10-01" + }, + { + "type": "Microsoft.Resources/deployments", + "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat('Hutopyvault', subscription().subscriptionId)))]", + "resourceGroup": "[parameters('resourceGroupName')]", + "apiVersion": "2019-10-01", + "dependsOn": [ + "[parameters('resourceGroupName')]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "name": "Hutopyvault", + "type": "Microsoft.KeyVault/vaults", + "location": "[parameters('resourceLocation')]", + "properties": { + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "5f2d8eae-fe77-483e-883b-0eac5059186a", + "accessPolicies": [], + "enabledForDeployment": true, + "enabledForDiskEncryption": true, + "enabledForTemplateDeployment": true + }, + "apiVersion": "2016-10-01" + } + ] + } + } + } + ], + "metadata": { + "_dependencyType": "secrets.keyVault" + } +} \ No newline at end of file diff --git a/backend/src/Web/Properties/launchSettings.json b/backend/src/Web/Properties/launchSettings.json new file mode 100644 index 0000000..39a9650 --- /dev/null +++ b/backend/src/Web/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:61846", + "sslPort": 44312 + } + }, + "profiles": { + "Hutopy.Web - DEV": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/backend/src/Web/Properties/serviceDependencies.Hutopy - Zip Deploy.json b/backend/src/Web/Properties/serviceDependencies.Hutopy - Zip Deploy.json new file mode 100644 index 0000000..346631c --- /dev/null +++ b/backend/src/Web/Properties/serviceDependencies.Hutopy - Zip Deploy.json @@ -0,0 +1,12 @@ +{ + "dependencies": { + "secrets1": { + "serviceConnectorResourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/Microsoft.Web/sites/Hutopy/providers/Microsoft.ServiceLinker/linkers/VaultUri_8A210A7503", + "secretStore": "AzureAppSettings", + "resourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/Microsoft.KeyVault/vaults/Hutopyvault", + "type": "secrets.keyVault", + "connectionId": "VaultUri", + "dynamicId": null + } + } +} \ No newline at end of file diff --git a/backend/src/Web/Properties/serviceDependencies.json b/backend/src/Web/Properties/serviceDependencies.json new file mode 100644 index 0000000..4f5d819 --- /dev/null +++ b/backend/src/Web/Properties/serviceDependencies.json @@ -0,0 +1,9 @@ +{ + "dependencies": { + "secrets1": { + "type": "secrets", + "connectionId": "VaultUri", + "dynamicId": null + } + } +} \ No newline at end of file diff --git a/backend/src/Web/Web.csproj b/backend/src/Web/Web.csproj new file mode 100644 index 0000000..68263b5 --- /dev/null +++ b/backend/src/Web/Web.csproj @@ -0,0 +1,45 @@ + + + + Hutopy.Web + Hutopy.Web + de6d03c4-8b1c-49e2-a8ca-c38cd4dc7d85 + + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/Web/Web.http b/backend/src/Web/Web.http new file mode 100644 index 0000000..a56ea97 --- /dev/null +++ b/backend/src/Web/Web.http @@ -0,0 +1,102 @@ +# For more info on HTTP files go to https://aka.ms/vs/httpfile +@Email=hutopy@test +@Password=Test123# +@auth_token= + +# POST Users Register +POST {{base_url}}/api/Users/Register +Content-Type: application/json + +{ + "email": "{{Email}}", + "password": "{{Password}}" +} + +### + +# POST Users Login +POST {{base_url}}/api/Users/login +Content-Type: application/json + +{ + "email": "{{Email}}", + "password": "{{Password}}" +} + +> {% + client.global.set("auth_token", response.body.accessToken); +%} + +### + +# POST Users Refresh +POST {{base_url}}/api/Users/Refresh +Authorization: Bearer {{auth_token}} +Content-Type: application/json + +{ + "refreshToken": "" +} + +### +# GET GetMyUser +GET {{base_url}}/api/GetMyUser +Authorization: Bearer {{auth_token}} + +### +# GET /api/posts + +GET {{base_url}}/api/messages/00000001-0000-0000-0000-000000000001 + +### +# GET /api/subscriptions +GET {{base_url}}/api/subscriptions +Authorization: Bearer {{auth_token}} + +### +# POST /api/creators/{CreatorId}/subscribe +POST {{base_url}}/api/creators/74d60ff6-bdd3-4347-acd8-08dcb43b3ea4/subscribe +Authorization: Bearer {{auth_token}} + +### +# GET /api/posts/by-user +GET {{base_url}}/api/messages/by-user/325C69E8-DBC4-4CEE-B56E-C3C90AEE7963 +Authorization: Bearer {{auth_token}} + +### +# GET /api/contents/creator/{CreatorId} +GET {{base_url}}/api/contents/creator/8590ba59-58a7-4466-bb50-08dcb5e47c6d/ + + +### +# GET /api/creators/{CreatorId}/colors +POST {{base_url}}/api/creators/8590ba59-58a7-4466-bb50-08dcb5e47c6d/colors/ +Authorization: Bearer {{auth_token}} +Content-Type: application/json + +{ + "Primary": "#fffff0", + "Secondary": "#fffff0", + "Background": "#fffff0", + "Surface": "#fffff0", + "Error": "#fffff0", + "OnPrimary": "#fffff0", + "OnSecondary": "#fffff0", + "OnBackground": "#fffff0", + "OnSurface": "#fffff0", + "OnError": "#fffff0" +} + + +### +# GET /api/tips/{CreatorId} +POST {{base_url}}/api/tips/8590ba59-58a7-4466-bb50-08dcb5e47c6d/ +Authorization: Bearer {{auth_token}} +Content-Type: application/json + +{ + "amount" : 12300, + "creatorId" : "9a150dea-edda-4b85-f17a-08dce560fa5c", + "currency" : "CAD", + "message" : "TEST" +} \ No newline at end of file diff --git a/backend/src/Web/appsettings.Development.json b/backend/src/Web/appsettings.Development.json new file mode 100644 index 0000000..538fd47 --- /dev/null +++ b/backend/src/Web/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "ConnectionStrings": { + "AzureBlob": "DefaultEndpointsProtocol=https;AccountName=hutopyblob;AccountKey=AjK9vQMCIAG280PFIAWDjTtiFc/fIj/fb1NA9xtC7SLm6ZMZa/Agg0JFGYcHuJEaoZKDme7mNx/U+AStt9o7LQ==;EndpointSuffix=core.windows.net", + "PostgresConnection": "Server=localhost,5432;Database=Hutopy;Uid=sa;Pwd=P@ssword123!;", + }, + "Authentication": { + "Jwt": { + "Lifetime": "1.00:00:00", + "Audience": "hutopy", + "Issuer": "https://auth.hutopy.com", + "Key": "b2df428b9929d3ace7c598bbf4e496b2f0b71ab3cd4f94540356cfc35b000000" + } + }, + "Stripe": { + "SecretKey": "sk_test_51OoveVDrRyqXtNdBaOs1DFFja0XhrQtJoAo83uSySMuqw4Wyt9NsuugrIHRqet9a50cr5GvolpTP8EZuTSttcgYx00gOUPNDoI", + "WebhookSecret": "whsec_cee07ef14cf784850cab63567048b5326fec7fd29c03f4659476524f8299aff1", + "HutopyRate": 0.05 + } +} diff --git a/backend/src/Web/appsettings.Production.json b/backend/src/Web/appsettings.Production.json new file mode 100644 index 0000000..71ad1d9 --- /dev/null +++ b/backend/src/Web/appsettings.Production.json @@ -0,0 +1,19 @@ +{ + "ConnectionStrings": { + "AzureBlob": "DefaultEndpointsProtocol=https;AccountName=hutopyblob;AccountKey=AjK9vQMCIAG280PFIAWDjTtiFc/fIj/fb1NA9xtC7SLm6ZMZa/Agg0JFGYcHuJEaoZKDme7mNx/U+AStt9o7LQ==;EndpointSuffix=core.windows.net", + "PostgresConnection": "Server=hutopypostgress.postgres.database.azure.com,5432;Database=hutopy;User Id=hutopy;Password=General2024!;Ssl Mode=Require;" + }, + "Authentication": { + "Jwt": { + "Lifetime": "1.00:00:00", + "Audience": "hutopy", + "Issuer": "https://auth.hutopy.com", + "Key": "b2df428b9929d3ace7c598bbf4e496b2f0b71ab3cd4f94540356cfc35b000000" + } + }, + "Stripe": { + "SecretKey": "sk_test_51OoveVDrRyqXtNdBaOs1DFFja0XhrQtJoAo83uSySMuqw4Wyt9NsuugrIHRqet9a50cr5GvolpTP8EZuTSttcgYx00gOUPNDoI", + "WebhookSecret": "whsec_cee07ef14cf784850cab63567048b5326fec7fd29c03f4659476524f8299aff1", + "HutopyRate": 0.05 + } +} \ No newline at end of file diff --git a/backend/src/Web/appsettings.json b/backend/src/Web/appsettings.json new file mode 100644 index 0000000..edd8c66 --- /dev/null +++ b/backend/src/Web/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Information", + "Microsoft.AspNetCore.SpaProxy": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "Authentication": { + "Jwt": { + "Lifetime": "00:30:00" + } + } +} \ No newline at end of file diff --git a/backend/src/Web/http-client.env.json b/backend/src/Web/http-client.env.json new file mode 100644 index 0000000..4af4838 --- /dev/null +++ b/backend/src/Web/http-client.env.json @@ -0,0 +1,5 @@ +{ + "dev": { + "base_url": "https://localhost:5001" + } +} \ No newline at end of file diff --git a/backend/src/Web/wwwroot/favicon.ico b/backend/src/Web/wwwroot/favicon.ico new file mode 100644 index 0000000..63e859b Binary files /dev/null and b/backend/src/Web/wwwroot/favicon.ico differ diff --git a/backend/start-infrastructure.sh b/backend/start-infrastructure.sh new file mode 100644 index 0000000..520e062 --- /dev/null +++ b/backend/start-infrastructure.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +docker run \ + --cap-add SYS_PTRACE \ + -e 'POSTGRES_USER=sa' \ + -e 'POSTGRES_PASSWORD=P@ssword123!' \ + -p 5432:5432 \ + --name postgres \ + -d postgres:latest \ No newline at end of file diff --git a/backend/stripe.exe b/backend/stripe.exe new file mode 100644 index 0000000..e7dab0e Binary files /dev/null and b/backend/stripe.exe differ diff --git a/backend/update-databases.sh b/backend/update-databases.sh new file mode 100644 index 0000000..53d7127 --- /dev/null +++ b/backend/update-databases.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +dotnet ef database update \ + --startup-project src/Web/Web.csproj \ + --project src/Web/Web.csproj \ + --context Hutopy.Web.Features.Users.Data.IdentityDbContext \ + --configuration Debug \ + --no-build + +dotnet ef database update \ + --startup-project src/Web/Web.csproj \ + --project src/Web/Web.csproj \ + --context Hutopy.Web.Features.Messages.Data.MessagingDbContext \ + --configuration Debug \ + --no-build + +dotnet ef database update \ + --startup-project src/Web/Web.csproj \ + --project src/Web/Web.csproj \ + --context Hutopy.Web.Features.Contents.Data.ContentDbContext \ + --configuration Debug \ + --no-build + +dotnet ef database update \ + --startup-project src/Web/Web.csproj \ + --project src/Web/Web.csproj \ + --context Hutopy.Web.Features.Memberships.Data.MembershipDbContext \ + --configuration Debug \ + --no-build +