Initial commit
FamilyNido — a self-hosted PWA for a single household: shared calendar, chores, meals, school agenda, health records and a family wall. One instance per family, deployable with `docker compose` on any home server. Stack: .NET 10 (ASP.NET Core Minimal APIs) + EF Core 10 + PostgreSQL 16 on the backend, Angular 21 (standalone, signals, zoneless) + Tailwind CSS v4 on the frontend, SignalR for realtime, optional OIDC alongside local credentials, integration via a versioned `/api/v1/**` public API. See README.md for the module overview and how to deploy.
This commit is contained in:
commit
a308041d59
492 changed files with 66240 additions and 0 deletions
38
.dockerignore
Normal file
38
.dockerignore
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Keep the build context lean for docker builds at the repo root.
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.gitattributes
|
||||||
|
**/.github
|
||||||
|
**/README.md
|
||||||
|
**/*.md
|
||||||
|
!CLAUDE.md
|
||||||
|
|
||||||
|
# .NET build artifacts
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
artifacts/
|
||||||
|
coverage/
|
||||||
|
TestResults/
|
||||||
|
|
||||||
|
# Node / Angular
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.angular
|
||||||
|
**/.cache
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
|
||||||
|
# Env files — never baked into images
|
||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/.env.*.local
|
||||||
|
|
||||||
|
# Design bundle (reference only, not needed at build time)
|
||||||
|
design/
|
||||||
|
|
||||||
|
# Compose artifacts
|
||||||
|
.docker-data/
|
||||||
389
.editorconfig
Normal file
389
.editorconfig
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# All files
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# Xml files
|
||||||
|
[*.xml]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Xml project files
|
||||||
|
[*.{csproj,fsproj,vbproj,proj,slnx}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Xml config files
|
||||||
|
[*.{props,targets,config,nuspec}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# C# files
|
||||||
|
[*.cs]
|
||||||
|
|
||||||
|
#### Core EditorConfig Options ####
|
||||||
|
|
||||||
|
# Indentation and spacing
|
||||||
|
indent_size = 4
|
||||||
|
tab_width = 4
|
||||||
|
|
||||||
|
# New line preferences
|
||||||
|
insert_final_newline = false
|
||||||
|
|
||||||
|
#### .NET Coding Conventions ####
|
||||||
|
[*.{cs,vb}]
|
||||||
|
|
||||||
|
# Organize usings
|
||||||
|
dotnet_separate_import_directive_groups = true
|
||||||
|
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_namespace_match_folder = 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_collection_expression = when_types_loosely_match: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_foreach_explicit_cast_in_source = when_strongly_typed: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_extended_property_pattern = 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_anonymous_function = true:suggestion
|
||||||
|
csharp_prefer_static_local_function = true:warning
|
||||||
|
csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion
|
||||||
|
csharp_style_prefer_readonly_struct = true:suggestion
|
||||||
|
csharp_style_prefer_readonly_struct_member = true:suggestion
|
||||||
|
|
||||||
|
# Code-block preferences
|
||||||
|
csharp_prefer_braces = true:silent
|
||||||
|
csharp_prefer_simple_using_statement = true:suggestion
|
||||||
|
csharp_style_namespace_declarations = file_scoped:suggestion
|
||||||
|
csharp_style_prefer_method_group_conversion = true:silent
|
||||||
|
csharp_style_prefer_primary_constructors = true:suggestion
|
||||||
|
csharp_style_prefer_top_level_statements = true:silent
|
||||||
|
|
||||||
|
# Expression-level preferences
|
||||||
|
csharp_prefer_simple_default_expression = true:suggestion
|
||||||
|
csharp_style_deconstructed_variable_declaration = true:suggestion
|
||||||
|
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
|
||||||
|
csharp_style_inlined_variable_declaration = true:suggestion
|
||||||
|
csharp_style_prefer_index_operator = true:suggestion
|
||||||
|
csharp_style_prefer_local_over_anonymous_function = true:suggestion
|
||||||
|
csharp_style_prefer_null_check_over_type_check = true:suggestion
|
||||||
|
csharp_style_prefer_range_operator = true:suggestion
|
||||||
|
csharp_style_prefer_tuple_swap = true:suggestion
|
||||||
|
csharp_style_prefer_utf8_string_literals = 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
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
76
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
76
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
name: 🐛 Bug report
|
||||||
|
description: Something is broken or behaving unexpectedly.
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to file a bug report. Please fill in the
|
||||||
|
fields below — the more specific the reproduction, the faster this
|
||||||
|
moves. **Do not file security issues here** — use the private
|
||||||
|
*Report a vulnerability* button on the Security tab.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: A clear and concise description of the bug.
|
||||||
|
placeholder: When I click X, the page errors with Y instead of doing Z.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Numbered list. Include the exact data or fixtures you used.
|
||||||
|
placeholder: |
|
||||||
|
1. Log in as an adult member
|
||||||
|
2. Navigate to /tasks
|
||||||
|
3. Click "New task" and submit with an empty title
|
||||||
|
4. Observe the 500 error in the network tab
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
placeholder: A 400 with a validation message, not a 500.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: deploy
|
||||||
|
attributes:
|
||||||
|
label: Deploy target
|
||||||
|
description: How are you running FamilyNido?
|
||||||
|
placeholder: docker compose on Debian 12 / native dev (dotnet run + ng serve) / …
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Git commit / version
|
||||||
|
description: Output of `git rev-parse --short HEAD` from your checkout, or the image tag you pulled.
|
||||||
|
placeholder: e.g. 2f3dcc7
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs and screenshots
|
||||||
|
description: Relevant log lines (API + browser console) and a screenshot if visual. Redact personal data before pasting.
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
description: Custom configuration, integrations enabled, edge cases — anything that might explain the behavior.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# NOTE: update the URLs below to match the actual <owner>/<repo> slug of the
|
||||||
|
# public repository after you create it on GitHub. They are templated against
|
||||||
|
# pablitofernandez/FamilyNido as a starting point.
|
||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 🔒 Security vulnerability
|
||||||
|
url: https://github.com/pablitofernandez/FamilyNido/security/advisories/new
|
||||||
|
about: Do NOT open a public issue. Report privately through GitHub's security advisories — see SECURITY.md.
|
||||||
|
- name: 💬 Question or discussion
|
||||||
|
url: https://github.com/pablitofernandez/FamilyNido/discussions
|
||||||
|
about: For open-ended questions, setup help and ideas, prefer Discussions over Issues.
|
||||||
47
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
47
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
name: ✨ Feature request
|
||||||
|
description: Propose a new feature or an improvement to an existing one.
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for thinking about FamilyNido's roadmap. Before filling this in,
|
||||||
|
please read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) — the
|
||||||
|
project is scoped to **a single household per instance**, and features
|
||||||
|
that primarily serve someone else's customers (multi-tenant SaaS,
|
||||||
|
commercial hosting, white-labelling) are out of scope.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What problem does this solve?
|
||||||
|
description: Describe the situation in your family that this would help with. The *why* matters much more than the *what*.
|
||||||
|
placeholder: Every morning we forget who is picking up the kids from school because…
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution (optional)
|
||||||
|
description: If you already have an idea of how this could work, jot it here. Otherwise leave blank — the design discussion can happen on the issue.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Existing workarounds you've tried (in FamilyNido or other tools) and why they fall short.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: Scope check
|
||||||
|
options:
|
||||||
|
- label: This is useful for a typical family running its own instance, not just my edge case.
|
||||||
|
required: true
|
||||||
|
- label: This does not require multi-tenant features, billing, white-labelling or hosting it as a service for other families.
|
||||||
|
required: true
|
||||||
37
.github/dependabot.yml
vendored
Normal file
37
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Dependabot keeps each ecosystem's dependencies fresh on its own cadence.
|
||||||
|
# Weekly batches keep the PR firehose manageable for a one-maintainer repo.
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "nuget"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "backend"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/web/familynido-web"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "frontend"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "ci"
|
||||||
|
|
||||||
|
- package-ecosystem: "docker"
|
||||||
|
directory: "/deploy"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "docker"
|
||||||
25
.github/pull_request_template.md
vendored
Normal file
25
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<!--
|
||||||
|
Thanks for opening a PR! Quick checklist before submitting:
|
||||||
|
|
||||||
|
- Non-trivial changes should have a linked issue discussing scope first.
|
||||||
|
- Match the existing code style (.editorconfig + Angular CLI schematics).
|
||||||
|
- API changes need at least one integration test under tests/FamilyNido.Tests/.
|
||||||
|
- UI changes should be verified in a browser before submitting.
|
||||||
|
- Keep the change focused on one concern.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- 1-2 sentences describing what changes and why. -->
|
||||||
|
|
||||||
|
## Linked issue
|
||||||
|
|
||||||
|
<!-- e.g. Closes #42, or "none — trivial fix". -->
|
||||||
|
|
||||||
|
## How was this tested?
|
||||||
|
|
||||||
|
<!-- "Ran dotnet test", "Played with the new flow in a browser using the demo seed", "Added X integration test", … -->
|
||||||
|
|
||||||
|
## Screenshots / clips (UI changes only)
|
||||||
|
|
||||||
|
<!-- Drop one or two before/after captures. Scrub real family data. -->
|
||||||
64
.github/workflows/build-and-push.yml
vendored
Normal file
64
.github/workflows/build-and-push.yml
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
name: Build and publish images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAMESPACE: ${{ github.repository_owner }}
|
||||||
|
# Run JS-based actions on Node 24 — Node 20 is deprecated on GH-hosted
|
||||||
|
# runners (forced switch 2026-06-02, removal 2026-09-16). Opt in early
|
||||||
|
# so the warning goes away and we don't get caught flat-footed.
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
image:
|
||||||
|
- name: familynido-api
|
||||||
|
dockerfile: deploy/api.Dockerfile
|
||||||
|
- name: familynido-web
|
||||||
|
dockerfile: deploy/web.Dockerfile
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Compute image metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image.name }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,format=short
|
||||||
|
|
||||||
|
- name: Build and push ${{ matrix.image.name }}
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.image.dockerfile }}
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha,scope=${{ matrix.image.name }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.image.name }}
|
||||||
300
.github/workflows/e2e.yml
vendored
Normal file
300
.github/workflows/e2e.yml
vendored
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
name: E2E (Playwright)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: e2e-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# Run JS-based actions (checkout, setup-dotnet, setup-node, etc.) on Node 24
|
||||||
|
# instead of the deprecated Node 20 runtime. Node 20 is being removed from
|
||||||
|
# GitHub-hosted runners on 2026-09-16; this opt-in silences the warning and
|
||||||
|
# avoids the forced switch on 2026-06-02.
|
||||||
|
# Ref: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
playwright:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: familynido
|
||||||
|
POSTGRES_USER: familynido
|
||||||
|
POSTGRES_PASSWORD: familynido
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U familynido -d familynido"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
# The seeded testers' credentials. Stable across runs because the
|
||||||
|
# E2ETestDataSeeder upserts on email — it never rotates an existing
|
||||||
|
# password — and these values are only ever real on this ephemeral
|
||||||
|
# Postgres instance, never in production.
|
||||||
|
E2E_USER_EMAIL: e2e-a@familynido.test
|
||||||
|
E2E_USER_PASSWORD: SuperSecret123!
|
||||||
|
E2E_USER_B_EMAIL: e2e-b@familynido.test
|
||||||
|
E2E_USER_B_PASSWORD: SuperSecret123!
|
||||||
|
E2E_USER_B_NAME: Tester B
|
||||||
|
E2E_BASE_URL: http://localhost:5173
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up .NET 10
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: web/familynido-web/package-lock.json
|
||||||
|
|
||||||
|
- name: Restore .NET
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Build & publish API
|
||||||
|
run: |
|
||||||
|
dotnet publish src/FamilyNido.Api -c Release -o publish/api --no-restore
|
||||||
|
ls -la publish/api/FamilyNido.Api.dll
|
||||||
|
|
||||||
|
- name: Install SPA deps
|
||||||
|
working-directory: web/familynido-web
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build SPA (es + en bundles)
|
||||||
|
working-directory: web/familynido-web
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install nginx
|
||||||
|
run: sudo apt-get update -y && sudo apt-get install -y nginx
|
||||||
|
|
||||||
|
- name: Configure nginx (proxy /api → :8080, serve SPA at :5173)
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
BUILT="$GITHUB_WORKSPACE/web/familynido-web/dist/familynido-web/browser"
|
||||||
|
if [ ! -d "$BUILT/es" ] || [ ! -d "$BUILT/en" ]; then
|
||||||
|
echo "Localized SPA bundles not found at $BUILT" >&2
|
||||||
|
ls -la "$BUILT" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy the SPA out of /home/runner/... — nginx runs as www-data
|
||||||
|
# and can't traverse into the runner's home tree by default. /var/www
|
||||||
|
# is the canonical "nginx-readable" location.
|
||||||
|
SPA_ROOT=/var/www/familynido-e2e
|
||||||
|
sudo rm -rf "$SPA_ROOT"
|
||||||
|
sudo mkdir -p "$SPA_ROOT"
|
||||||
|
sudo cp -r "$BUILT"/. "$SPA_ROOT/"
|
||||||
|
sudo chown -R www-data:www-data "$SPA_ROOT"
|
||||||
|
sudo find "$SPA_ROOT" -type d -exec chmod 755 {} \;
|
||||||
|
sudo find "$SPA_ROOT" -type f -exec chmod 644 {} \;
|
||||||
|
|
||||||
|
echo "── /var/www/familynido-e2e tree ──"
|
||||||
|
ls -la "$SPA_ROOT"
|
||||||
|
ls -la "$SPA_ROOT/es" | head -n 5
|
||||||
|
|
||||||
|
sudo tee /etc/nginx/sites-available/familynido-e2e > /dev/null <<EOF
|
||||||
|
server {
|
||||||
|
listen 5173 default_server;
|
||||||
|
server_name _;
|
||||||
|
root $SPA_ROOT;
|
||||||
|
index index.html;
|
||||||
|
absolute_redirect off;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = / {
|
||||||
|
if (\$http_accept_language ~* "^en") { return 302 /en/; }
|
||||||
|
return 302 /es/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /es/ { try_files \$uri /es/index.html; }
|
||||||
|
location /en/ { try_files \$uri /en/index.html; }
|
||||||
|
|
||||||
|
# Anything else (e.g. /tasks, /calendar) is a Playwright spec
|
||||||
|
# navigating without the locale prefix. Redirect through to
|
||||||
|
# /es/<path> so the SPA loads with the right base href instead
|
||||||
|
# of nginx returning its default 404.
|
||||||
|
location / {
|
||||||
|
return 302 /es\$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/default
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/familynido-e2e /etc/nginx/sites-enabled/familynido-e2e
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
- name: Start API + wait for ready (combined step)
|
||||||
|
env:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Testing
|
||||||
|
# Bind to all interfaces so curl on 127.0.0.1 reaches Kestrel
|
||||||
|
# regardless of how the runner resolves localhost.
|
||||||
|
ASPNETCORE_URLS: http://+:8080
|
||||||
|
# Disable Serilog buffering — write to console with line-buffered
|
||||||
|
# output so we see startup messages in real time.
|
||||||
|
Serilog__MinimumLevel: Information
|
||||||
|
DOTNET_PRINT_TELEMETRY_MESSAGE: 'false'
|
||||||
|
DOTNET_NOLOGO: '1'
|
||||||
|
ConnectionStrings__Postgres: Host=localhost;Port=5432;Database=familynido;Username=familynido;Password=familynido
|
||||||
|
Family__AutoMigrate: 'true'
|
||||||
|
Seed__E2E__Enabled: 'true'
|
||||||
|
# Pin the seeded family's timezone to UTC so the date the browser
|
||||||
|
# computes via `new Date()` (UTC in CI) and the date the server
|
||||||
|
# resolves via `family.TimeZone` always agree. Otherwise a CI run
|
||||||
|
# crossing midnight UTC pushes a task into a day the server's
|
||||||
|
# "today" doesn't include yet, and `/api/household-tasks/today`
|
||||||
|
# returns an empty list.
|
||||||
|
Seed__E2E__TimeZone: 'UTC'
|
||||||
|
Seed__E2E__UserAEmail: ${{ env.E2E_USER_EMAIL }}
|
||||||
|
Seed__E2E__UserAPassword: ${{ env.E2E_USER_PASSWORD }}
|
||||||
|
Seed__E2E__UserBEmail: ${{ env.E2E_USER_B_EMAIL }}
|
||||||
|
Seed__E2E__UserBPassword: ${{ env.E2E_USER_B_PASSWORD }}
|
||||||
|
Seed__E2E__UserBDisplayName: ${{ env.E2E_USER_B_NAME }}
|
||||||
|
Email__Enabled: 'false'
|
||||||
|
Email__AppBaseUrl: http://localhost:5173
|
||||||
|
# Calendar sync is dormant in CI — the loop kicks off but never
|
||||||
|
# acts because no Google account is linked.
|
||||||
|
Calendar__SyncIntervalMinutes: '999999'
|
||||||
|
run: |
|
||||||
|
set -uxo pipefail
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
echo "── dotnet --info ──"
|
||||||
|
dotnet --info | head -n 30
|
||||||
|
|
||||||
|
echo "── DLL stat ──"
|
||||||
|
ls -la "$GITHUB_WORKSPACE/publish/api/FamilyNido.Api.dll"
|
||||||
|
|
||||||
|
# `stdbuf -o0 -e0` forces unbuffered stdio so any framework or
|
||||||
|
# Serilog writes appear in api.log immediately, not after a 4 KB
|
||||||
|
# block fills. cd into the publish dir so config files next to
|
||||||
|
# the DLL (appsettings.json, etc.) are found.
|
||||||
|
cd "$GITHUB_WORKSPACE/publish/api"
|
||||||
|
stdbuf -o0 -e0 dotnet ./FamilyNido.Api.dll \
|
||||||
|
> "$GITHUB_WORKSPACE/logs/api.log" 2>&1 &
|
||||||
|
API_PID=$!
|
||||||
|
echo "$API_PID" > "$GITHUB_WORKSPACE/logs/api.pid"
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
echo "API started with PID $API_PID"
|
||||||
|
|
||||||
|
# 5 s grace period for first writes / JIT warm-up.
|
||||||
|
sleep 5
|
||||||
|
echo "── after 5 s, api.log ──"
|
||||||
|
cat logs/api.log || true
|
||||||
|
echo "── ports listening ──"
|
||||||
|
ss -tlnp 2>/dev/null | head -n 30 || true
|
||||||
|
|
||||||
|
# Wait loop with periodic snapshots.
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
if curl -fsS http://127.0.0.1:8080/health/ready > /dev/null; then
|
||||||
|
echo "API ready after ${i}s"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$API_PID" 2>/dev/null; then
|
||||||
|
echo "API process exited; dumping log:" >&2
|
||||||
|
cat logs/api.log >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ $((i % 10)) -eq 0 ]; then
|
||||||
|
echo "── snapshot @ ${i}s ──" >&2
|
||||||
|
ss -tlnp 2>/dev/null | grep -E ':(8080|5432) ' >&2 || true
|
||||||
|
echo "── api.log tail ──" >&2
|
||||||
|
tail -n 40 logs/api.log >&2 || true
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "API never became ready" >&2
|
||||||
|
echo "── Final full api.log ──" >&2
|
||||||
|
cat logs/api.log >&2 || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Start nginx
|
||||||
|
run: sudo systemctl restart nginx
|
||||||
|
|
||||||
|
- name: Wait for SPA root
|
||||||
|
run: |
|
||||||
|
set -uxo pipefail
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsS -o /dev/null -L http://127.0.0.1:5173/es/; then
|
||||||
|
echo "SPA ready after ${i}s"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "SPA never became reachable. Diagnostics:" >&2
|
||||||
|
echo "── curl -i (verbose response) ──" >&2
|
||||||
|
curl -i http://127.0.0.1:5173/es/ 2>&1 | head -n 30 >&2 || true
|
||||||
|
echo "── nginx error.log ──" >&2
|
||||||
|
sudo tail -n 50 /var/log/nginx/error.log >&2 || true
|
||||||
|
echo "── nginx access.log ──" >&2
|
||||||
|
sudo tail -n 20 /var/log/nginx/access.log >&2 || true
|
||||||
|
echo "── nginx process ──" >&2
|
||||||
|
ps aux | grep nginx | grep -v grep >&2 || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
working-directory: web/familynido-web
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run Playwright
|
||||||
|
working-directory: web/familynido-web
|
||||||
|
run: npx playwright test
|
||||||
|
|
||||||
|
- name: Stop API
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f logs/api.pid ]; then
|
||||||
|
kill "$(cat logs/api.pid)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload Playwright report on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: web/familynido-web/playwright-report
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Upload Playwright traces on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-traces
|
||||||
|
path: web/familynido-web/test-results
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Upload API log on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: api-log
|
||||||
|
path: logs/api.log
|
||||||
|
retention-days: 14
|
||||||
73
.gitignore
vendored
Normal file
73
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# ─── .NET ─────────────────────────────────────────────
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userprefs
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.lock.json
|
||||||
|
project.lock.json
|
||||||
|
artifacts/
|
||||||
|
coverage/
|
||||||
|
*.coverage
|
||||||
|
*.trx
|
||||||
|
TestResults/
|
||||||
|
|
||||||
|
# ─── Angular / Node ───────────────────────────────────
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.angular/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.cache/
|
||||||
|
.parcel-cache/
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# ─── Environment & secrets ────────────────────────────
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.pfx
|
||||||
|
*.snk
|
||||||
|
|
||||||
|
# ─── Local tool state ─────────────────────────────────
|
||||||
|
# Claude Code per-repo permissions and preferences.
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# ─── OS ───────────────────────────────────────────────
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
.DS_Store
|
||||||
|
ehthumbs.db
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# ─── Editor ───────────────────────────────────────────
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# ─── Docker ───────────────────────────────────────────
|
||||||
|
.docker-data/
|
||||||
|
|
||||||
|
# ─── EF Core artifacts ────────────────────────────────
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# ─── Temporary / local scratch ────────────────────────
|
||||||
|
scratch/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# ─── Local uploads (wall images, future attachments) ──
|
||||||
|
# Created by the Api when running in dev; production uses the
|
||||||
|
# `familynido-files` Docker volume and never hits this path.
|
||||||
|
src/FamilyNido.Api/data/
|
||||||
|
|
||||||
|
# ─── Personal design / spec notes kept out of the public repo ─────
|
||||||
|
FamilyNido.md
|
||||||
|
design/
|
||||||
|
|
||||||
169
CLAUDE.md
Normal file
169
CLAUDE.md
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
|
||||||
|
# C# Development SECTION START
|
||||||
|
|
||||||
|
---
|
||||||
|
description: 'Guidelines for building C# applications'
|
||||||
|
applyTo: '**/*.cs'
|
||||||
|
---
|
||||||
|
|
||||||
|
## C# Instructions
|
||||||
|
- Always use the latest version C#, currently C# 14 features.
|
||||||
|
- Write clear and concise comments for each function.
|
||||||
|
|
||||||
|
## General Instructions
|
||||||
|
- Make only high confidence suggestions when reviewing code changes.
|
||||||
|
- Write code with good maintainability practices, including comments on why certain design decisions were made.
|
||||||
|
- Handle edge cases and write clear exception handling.
|
||||||
|
- For libraries or external dependencies, mention their usage and purpose in comments.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- Follow PascalCase for component names, method names, and public members.
|
||||||
|
- Use camelCase for private fields and local variables.
|
||||||
|
- Prefix interface names with "I" (e.g., IUserService).
|
||||||
|
|
||||||
|
## Formatting
|
||||||
|
|
||||||
|
- Apply code-formatting style defined in `.editorconfig`.
|
||||||
|
- Prefer file-scoped namespace declarations and single-line using directives.
|
||||||
|
- Insert a newline before the opening curly brace of any code block (e.g., after `if`, `for`, `while`, `foreach`, `using`, `try`, etc.).
|
||||||
|
- Ensure that the final return statement of a method is on its own line.
|
||||||
|
- Use pattern matching and switch expressions wherever possible.
|
||||||
|
- Use `nameof` instead of string literals when referring to member names.
|
||||||
|
- Ensure that XML doc comments are created for any public APIs. When applicable, include `<example>` and `<code>` documentation in the comments.
|
||||||
|
|
||||||
|
## Project Setup and Structure
|
||||||
|
|
||||||
|
- Guide users through creating a new .NET project with the appropriate templates.
|
||||||
|
- Explain the purpose of each generated file and folder to build understanding of the project structure.
|
||||||
|
- Demonstrate how to organize code using feature folders or domain-driven design principles.
|
||||||
|
- Show proper separation of concerns with models, services, and data access layers.
|
||||||
|
- Explain the Program.cs and configuration system in ASP.NET Core 10 including environment-specific settings.
|
||||||
|
|
||||||
|
## Nullable Reference Types
|
||||||
|
|
||||||
|
- Declare variables non-nullable, and check for `null` at entry points.
|
||||||
|
- Always use `is null` or `is not null` instead of `== null` or `!= null`.
|
||||||
|
- Trust the C# null annotations and don't add null checks when the type system says a value cannot be null.
|
||||||
|
|
||||||
|
# C# Development SECTION END
|
||||||
|
|
||||||
|
# Angular Development Instructions SECTION START
|
||||||
|
|
||||||
|
description: 'Angular-specific coding standards and best practices'
|
||||||
|
applyTo: '**/*.ts, **/*.html, **/*.scss, **/*.css'
|
||||||
|
---
|
||||||
|
|
||||||
|
Instructions for generating high-quality Angular applications with TypeScript, using Angular Signals for state management, adhering to Angular best practices as outlined at https://angular.dev.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
- Latest Angular version (use standalone components by default)
|
||||||
|
- TypeScript for type safety
|
||||||
|
- Angular CLI for project setup and scaffolding
|
||||||
|
- Follow Angular Style Guide (https://angular.dev/style-guide)
|
||||||
|
|
||||||
|
## Development Standards
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- Use standalone components unless modules are explicitly required
|
||||||
|
- Organize code by standalone feature modules or domains for scalability
|
||||||
|
- Implement lazy loading for feature modules to optimize performance
|
||||||
|
- Use Angular's built-in dependency injection system effectively
|
||||||
|
- Structure components with a clear separation of concerns (smart vs. presentational components)
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
- Enable strict mode in `tsconfig.json` for type safety
|
||||||
|
- Define clear interfaces and types for components, services, and models
|
||||||
|
- Use type guards and union types for robust type checking
|
||||||
|
- Implement proper error handling with RxJS operators (e.g., `catchError`)
|
||||||
|
- Use typed forms (e.g., `FormGroup`, `FormControl`) for reactive forms
|
||||||
|
|
||||||
|
### Component Design
|
||||||
|
- Follow Angular's component lifecycle hooks best practices
|
||||||
|
- When using Angular >= 19, Use `input()` `output()`, `viewChild()`, `viewChildren()`, `contentChild()` and `contentChildren()` functions instead of decorators; otherwise use decorators
|
||||||
|
- Leverage Angular's change detection strategy (default or `OnPush` for performance)
|
||||||
|
- Keep templates clean and logic in component classes or services
|
||||||
|
- Use Angular directives and pipes for reusable functionality
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- Use Angular's component-level CSS encapsulation (default: ViewEncapsulation.Emulated)
|
||||||
|
- Prefer SCSS for styling with consistent theming
|
||||||
|
- Implement responsive design using CSS Grid, Flexbox, or Angular CDK Layout utilities
|
||||||
|
- Follow Angular Material's theming guidelines if used
|
||||||
|
- Maintain accessibility (a11y) with ARIA attributes and semantic HTML
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Use Angular Signals for reactive state management in components and services
|
||||||
|
- Leverage `signal()`, `computed()`, and `effect()` for reactive state updates
|
||||||
|
- Use writable signals for mutable state and computed signals for derived state
|
||||||
|
- Handle loading and error states with signals and proper UI feedback
|
||||||
|
- Use Angular's `AsyncPipe` to handle observables in templates when combining signals with RxJS
|
||||||
|
|
||||||
|
### Data Fetching
|
||||||
|
- Use Angular's `HttpClient` for API calls with proper typing
|
||||||
|
- Implement RxJS operators for data transformation and error handling
|
||||||
|
- Use Angular's `inject()` function for dependency injection in standalone components
|
||||||
|
- Implement caching strategies (e.g., `shareReplay` for observables)
|
||||||
|
- Store API response data in signals for reactive updates
|
||||||
|
- Handle API errors with global interceptors for consistent error handling
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Sanitize user inputs using Angular's built-in sanitization
|
||||||
|
- Implement route guards for authentication and authorization
|
||||||
|
- Use Angular's `HttpInterceptor` for CSRF protection and API authentication headers
|
||||||
|
- Validate form inputs with Angular's reactive forms and custom validators
|
||||||
|
- Follow Angular's security best practices (e.g., avoid direct DOM manipulation)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Enable production builds with `ng build --prod` for optimization
|
||||||
|
- Use lazy loading for routes to reduce initial bundle size
|
||||||
|
- Optimize change detection with `OnPush` strategy and signals for fine-grained reactivity
|
||||||
|
- Use trackBy in `ngFor` loops to improve rendering performance
|
||||||
|
- Implement server-side rendering (SSR) or static site generation (SSG) with Angular Universal (if specified)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Write unit tests for components, services, and pipes using Jasmine and Karma
|
||||||
|
- Use Angular's `TestBed` for component testing with mocked dependencies
|
||||||
|
- Test signal-based state updates using Angular's testing utilities
|
||||||
|
- Write end-to-end tests with Cypress or Playwright (if specified)
|
||||||
|
- Mock HTTP requests using `provideHttpClientTesting`
|
||||||
|
- Ensure high test coverage for critical functionality
|
||||||
|
|
||||||
|
## Additional Guidelines
|
||||||
|
- **File naming (classic Angular convention, project-wide)**: each Angular `@Component` lives in its own folder with **three separated files** — `feature.component.ts` (logic), `feature.component.html` (template) and `feature.component.css` (component-scoped styles, created even if empty so the structure is uniform). The exported class must carry the `Component` suffix, e.g. `export class FeatureComponent { … }`. Services keep the `feature.service.ts` convention, guards `feature.guard.ts`, interceptors `feature.interceptor.ts`, models no suffix. The Angular CLI schematics in `angular.json` are already configured to produce this layout — always generate new components with `ng generate component <name>`.
|
||||||
|
- Use Angular CLI commands for generating boilerplate code
|
||||||
|
- Document components and services with clear JSDoc comments
|
||||||
|
- Ensure accessibility compliance (WCAG 2.1) where applicable
|
||||||
|
- Use Angular's built-in i18n for internationalization (if specified)
|
||||||
|
- Keep code DRY by creating reusable utilities and shared modules
|
||||||
|
- Use signals consistently for state management to ensure reactive updates
|
||||||
|
|
||||||
|
|
||||||
|
# Angular Development Instructions SECTION END
|
||||||
|
|
||||||
|
### Code Intelligence
|
||||||
|
|
||||||
|
Prefer LSP over Grep/Glob/Read for code navigation:
|
||||||
|
- `goToDefinition` / `goToImplementation` to jump to source
|
||||||
|
- `findReferences` to see all usages across the codebase
|
||||||
|
- `workspaceSymbol` to find where something is defined
|
||||||
|
- `documentSymbol` to list all symbols in a file
|
||||||
|
- `hover` for type info without reading the file
|
||||||
|
- `incomingCalls` / `outgoingCalls` for call hierarchy
|
||||||
|
|
||||||
|
Before renaming or changing a function signature, use
|
||||||
|
`findReferences` to find all call sites first.
|
||||||
|
|
||||||
|
Use Grep/Glob only for text/pattern searches (comments,
|
||||||
|
strings, config values) where LSP doesn't help.
|
||||||
|
|
||||||
|
After writing or editing code, check LSP diagnostics before
|
||||||
|
moving on. Fix any type errors or missing imports immediately.
|
||||||
|
|
||||||
|
.NET Instructions
|
||||||
|
|
||||||
|
Use a Vertical Slice Architecture.
|
||||||
26
CONTRIBUTING.md
Normal file
26
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Contributing to FamilyNido
|
||||||
|
|
||||||
|
FamilyNido is a personal side project maintained in evenings and weekends.
|
||||||
|
It is scoped on purpose to be a self-hosted PWA for **a single household per
|
||||||
|
instance** — multi-tenant SaaS, commercial hosting, white-labelling and
|
||||||
|
anything whose primary value is serving someone else's customers is **out of
|
||||||
|
scope**. Not because those things are bad, but because supporting them would
|
||||||
|
break the simplicity that makes this project maintainable for one person.
|
||||||
|
The MIT license is exactly the right tool if you want to fork and go that way.
|
||||||
|
|
||||||
|
Bug reports and small, focused PRs are welcome. For non-trivial changes
|
||||||
|
please open an issue first to align on scope before you write code — I would
|
||||||
|
rather say *"this won't be merged because X"* before you spend a weekend on it
|
||||||
|
than after. PR review cadence is best-effort: this is a hobby project, so
|
||||||
|
family and work come first; expect days, sometimes weeks. If you touch the
|
||||||
|
API please add at least one integration test under `tests/FamilyNido.Tests/`;
|
||||||
|
if you touch the UI please verify the change in a browser before submitting
|
||||||
|
(`docker compose -f deploy/docker-compose.dev.yml up -d`, `dotnet watch run`
|
||||||
|
in `src/FamilyNido.Api`, `npm start` in `web/familynido-web`). Match the
|
||||||
|
existing style — the `.editorconfig` and the Angular CLI schematics already
|
||||||
|
encode it.
|
||||||
|
|
||||||
|
For security problems do **not** open a public issue. Use the *Report a
|
||||||
|
vulnerability* button on the GitHub **Security** tab — the full policy lives
|
||||||
|
in [`SECURITY.md`](./SECURITY.md). For everything else, be kind, assume good
|
||||||
|
intent and keep the discussion technical.
|
||||||
16
Directory.Build.props
Normal file
16
Directory.Build.props
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<WarningsAsErrors />
|
||||||
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
|
<Deterministic>true</Deterministic>
|
||||||
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
10
FamilyNido.slnx
Normal file
10
FamilyNido.slnx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/FamilyNido.Api/FamilyNido.Api.csproj" />
|
||||||
|
<Project Path="src/FamilyNido.Domain/FamilyNido.Domain.csproj" />
|
||||||
|
<Project Path="src/FamilyNido.Persistence/FamilyNido.Persistence.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/FamilyNido.Tests/FamilyNido.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Pablo Fernández
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
350
README.md
Normal file
350
README.md
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
# 🏡 FamilyNido
|
||||||
|
|
||||||
|
> The digital nest for a single household — calendars, chores, meals, school,
|
||||||
|
> health and family conversations, all in one place. Self-hosted, ad-free,
|
||||||
|
> telemetry-free, one instance per family.
|
||||||
|
|
||||||
|
Built as a PWA you install on every phone, tablet and laptop in the house.
|
||||||
|
Designed to be deployed in ten minutes on any home server with `docker compose`.
|
||||||
|
|
||||||
|
> 🤖 **Pair-programmed end-to-end with [Claude Code](https://claude.com/claude-code)
|
||||||
|
> (Opus 4.7).** This repository is an experiment in how far a single developer
|
||||||
|
> can go shipping a real product side-by-side with an AI coding partner —
|
||||||
|
> architecture, vertical slices, tests, infra, docs and this very README.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Highlights
|
||||||
|
|
||||||
|
- 🧩 **Modular by design** — each feature is an isolated Angular route on the
|
||||||
|
frontend and a vertical slice on the backend; add, remove or extend without
|
||||||
|
cross-cutting changes.
|
||||||
|
- 📱 **Installable PWA** — works on iPhone, Android and desktop; the manifest
|
||||||
|
registers it as a native-feeling app on the home screen.
|
||||||
|
- 🔐 **Private** — your data lives on your hardware. Local credentials by
|
||||||
|
default, OIDC (PocketID, Dex, Keycloak, Authelia…) optional.
|
||||||
|
- 🎮 **Gamified chores** — every completed task awards points; a per-period
|
||||||
|
scoreboard makes contribution visible without nagging.
|
||||||
|
- 📅 **Bring-your-own-calendar** — read-only mirror of as many Google Calendar
|
||||||
|
accounts as the family has (one-way: Google → FamilyNido).
|
||||||
|
- 📺 **Tablet mode** — a fullscreen rotating dashboard for the kitchen tablet
|
||||||
|
stuck to the wall.
|
||||||
|
- 🔌 **Open public API** — drop tasks in from n8n, IFTTT, an iOS shortcut, a
|
||||||
|
Home Assistant automation or `curl`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Modules
|
||||||
|
|
||||||
|
Flat list of everything FamilyNido ships with. Every module is opt-in: hide it
|
||||||
|
from the navigation if your family doesn't use it, or extend it by adding files
|
||||||
|
to its vertical slice.
|
||||||
|
|
||||||
|
### 📅 Calendar
|
||||||
|
|
||||||
|
The shared family calendar. Each event can be tagged to specific members so the
|
||||||
|
tablet on the wall only shows "today's stuff for *me*". Optional one-way mirror
|
||||||
|
of Google Calendar — link as many Google accounts as the family has, pick which
|
||||||
|
sub-calendars to import, and a background worker pulls fresh deltas every few
|
||||||
|
minutes. Edits made inside FamilyNido stay in FamilyNido; nothing is written
|
||||||
|
back to Google.
|
||||||
|
|
||||||
|
### 💬 Wall
|
||||||
|
|
||||||
|
The household's message board. Markdown-formatted posts with emoji reactions,
|
||||||
|
threaded comments, pin-to-top and per-user unread counts. Use it for the
|
||||||
|
running shopping list, *"remember to call grandma tomorrow"* or sharing the
|
||||||
|
photo of yesterday's school play.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### ✅ Tasks
|
||||||
|
|
||||||
|
The chore tracker. One-off and recurring household tasks (daily, weekly,
|
||||||
|
custom), a **Today** tab, a 7-day **Week** tab and a backlog of *floating*
|
||||||
|
tasks with no due date. Completing a task awards points to whoever ticked it
|
||||||
|
off; tasks can also be pre-assigned to a specific member.
|
||||||
|
|
||||||
|
### 🍽️ Meals
|
||||||
|
|
||||||
|
The weekly meal plan. Seven days × multiple slots (breakfast, lunch, snack,
|
||||||
|
dinner). Smart suggestions based on what the family has eaten recently to keep
|
||||||
|
variety up and decision fatigue down. One-click *"duplicate last week"* for
|
||||||
|
batch planners.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 🩺 Health
|
||||||
|
|
||||||
|
Per-member health records. Medical profile (blood type, allergies, chronic
|
||||||
|
conditions), active medications with posology and vaccination history. So the
|
||||||
|
next pediatrician visit doesn't need a frantic search across three photo
|
||||||
|
libraries and four chat threads.
|
||||||
|
|
||||||
|
### 🎒 School
|
||||||
|
|
||||||
|
The kids' school agenda. Daily class schedule per member (with per-day
|
||||||
|
overrides for substitute teachers or field trips), school holidays and
|
||||||
|
extracurricular activities with recurring slots plus one-off exceptions. The
|
||||||
|
tablet's *"Cole"* page summarises tomorrow's gear list.
|
||||||
|
|
||||||
|
### 🏆 Scores
|
||||||
|
|
||||||
|
The gamification layer for tasks. Each completion earns points proportional to
|
||||||
|
the task's weight; the scoreboard ranks members for the current period. The
|
||||||
|
point of points: making contribution visible without anybody having to count
|
||||||
|
out loud.
|
||||||
|
|
||||||
|
### 🗓️ Member agenda
|
||||||
|
|
||||||
|
Per-member recurring patterns — *"Mum works Mon–Thu 9–18"*, *"Charlie has
|
||||||
|
gymnastics Tuesdays 18:00"*. Used by the dashboard widgets and the tablet to
|
||||||
|
answer *"who's where right now?"*. Patterns support exceptions (cancellations,
|
||||||
|
one-off swaps) without having to rewrite the rule.
|
||||||
|
|
||||||
|
### 🪺 Nido
|
||||||
|
|
||||||
|
The family roster. Admins add members (kids without accounts, adults with),
|
||||||
|
invite adults by email through single-use signed links, promote or demote
|
||||||
|
admins, upload member avatars and review pending invitations.
|
||||||
|
|
||||||
|
### 📺 Tablet mode
|
||||||
|
|
||||||
|
A fullscreen ambient dashboard meant for the kitchen tablet stuck to the wall.
|
||||||
|
Big typography, six rotating pages (Home, Cole, Tasks, Calendar, Meals, Wall),
|
||||||
|
auto-cycles every minute, refreshes every five and asks the browser to keep
|
||||||
|
the screen awake. Lives outside the regular nav so it doubles as an information
|
||||||
|
radiator.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 🏠 Home dashboard
|
||||||
|
|
||||||
|
The landing screen every user opens to. Built from configurable widgets —
|
||||||
|
each user picks which ones they care about (today's events, pending tasks,
|
||||||
|
weather, scoreboard, school day…) and the order.
|
||||||
|
|
||||||
|
### 🌤️ Weather
|
||||||
|
|
||||||
|
A weather widget pinned to the family's configured location. Powered by
|
||||||
|
Open-Meteo (no API key, no ads, no tracking). Surfaces on the home dashboard
|
||||||
|
and the tablet.
|
||||||
|
|
||||||
|
### 🔔 Notifications
|
||||||
|
|
||||||
|
Daily email digests summarising what's pending plus per-event mails. Each user
|
||||||
|
toggles which categories they want from the account screen. Push notifications
|
||||||
|
via VAPID for browsers that support them.
|
||||||
|
|
||||||
|
### 🔑 Account
|
||||||
|
|
||||||
|
Personal settings — change password, switch UI language (🇪🇸 Spanish /
|
||||||
|
🇬🇧 English), tweak notification preferences, see and revoke linked
|
||||||
|
credentials.
|
||||||
|
|
||||||
|
### 🔌 Integrations
|
||||||
|
|
||||||
|
A versioned public API at `/api/v1/**` authenticated by per-family API keys
|
||||||
|
(`X-Api-Key` header). First endpoint shipped: `POST /api/v1/tasks`. Wire it to
|
||||||
|
n8n, IFTTT, iOS shortcuts, Home Assistant or your own scripts. See
|
||||||
|
[`docs/integrations/README.md`](./docs/integrations/README.md) for the full
|
||||||
|
reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
| -------- | ---------------------------------------------------------------- |
|
||||||
|
| Frontend | Angular 21 (standalone, signals, zoneless) + Tailwind CSS v4 |
|
||||||
|
| Backend | .NET 10 (ASP.NET Core Minimal APIs) + EF Core 10 |
|
||||||
|
| Database | PostgreSQL 16 |
|
||||||
|
| Auth | Local credentials by default · optional OIDC (PocketID, Dex, …) |
|
||||||
|
| Realtime | SignalR |
|
||||||
|
| Deploy | Docker Compose + external Traefik |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
FamilyNido/
|
||||||
|
├── src/
|
||||||
|
│ ├── FamilyNido.Api/ # ASP.NET Core Minimal APIs, SignalR hub, vertical slices
|
||||||
|
│ ├── FamilyNido.Domain/ # Aggregates, value objects, pure domain rules
|
||||||
|
│ └── FamilyNido.Persistence/ # EF Core DbContext, EntityTypeConfigurations, migrations
|
||||||
|
├── tests/
|
||||||
|
│ └── FamilyNido.Tests/ # xUnit + Testcontainers integration suite
|
||||||
|
├── web/
|
||||||
|
│ └── familynido-web/ # Angular app (Tailwind v4, Fraunces/Nunito)
|
||||||
|
├── deploy/
|
||||||
|
│ ├── docker-compose.prod.yml # Production stack — pulls images from GHCR
|
||||||
|
│ ├── docker-compose.dev.yml # Development stack — Postgres only
|
||||||
|
│ ├── api.Dockerfile
|
||||||
|
│ ├── web.Dockerfile
|
||||||
|
│ ├── nginx/default.conf
|
||||||
|
│ └── .env.example
|
||||||
|
└── docs/
|
||||||
|
└── integrations/ # Public API reference for external integrations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Local requirements
|
||||||
|
|
||||||
|
- **.NET 10 SDK** (`dotnet --version` ≥ 10.0)
|
||||||
|
- **Node.js 22+** and **npm 10+**
|
||||||
|
- **Docker 28+** with Compose v2
|
||||||
|
- *(optional)* PostgreSQL 16 if you prefer not to use the Docker database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Development
|
||||||
|
|
||||||
|
### 1. Start the dev infrastructure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Brings up **PostgreSQL 16** on `localhost:5435`
|
||||||
|
(db / user / password: `familynido` / `familynido` / `familynido`).
|
||||||
|
|
||||||
|
The dev stack authenticates exclusively against local credentials
|
||||||
|
(`POST /api/auth/local/login`). The login screen hides the *"Continue with
|
||||||
|
PocketID"* button when `Oidc:Authority` is empty — the default in
|
||||||
|
`appsettings.Development.json`. To test the OIDC flow locally, fill in
|
||||||
|
`Oidc:Authority`, `Oidc:ClientId` and `Oidc:ClientSecret` and the button
|
||||||
|
reappears on the next page load.
|
||||||
|
|
||||||
|
### 2. Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/FamilyNido.Api
|
||||||
|
dotnet watch run
|
||||||
|
```
|
||||||
|
|
||||||
|
Listens on `http://localhost:5080`. Applies EF Core migrations on startup.
|
||||||
|
|
||||||
|
### 3. Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/familynido-web
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Listens on `http://localhost:4200`. `/api` requests are proxied to the backend
|
||||||
|
via `proxy.conf.json`.
|
||||||
|
|
||||||
|
### 4. *(optional)* Seed a curated demo family
|
||||||
|
|
||||||
|
If you want to explore the UI without going through the onboarding flow, the
|
||||||
|
API ships a **demo seed** that drops a fictitious *Smith Family* (two adults,
|
||||||
|
two kids) with tasks, wall posts, a populated meal plan and a school
|
||||||
|
schedule. Off by default — flip `Seed:Demo:Enabled` and provide a password to
|
||||||
|
turn it on:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/FamilyNido.Api
|
||||||
|
Seed__Demo__Enabled=true \
|
||||||
|
Seed__Demo__AdminPassword=DemoPass123! \
|
||||||
|
dotnet watch run
|
||||||
|
```
|
||||||
|
|
||||||
|
Log in as `dan@familynido.demo` with the password you set. The seed is
|
||||||
|
**Development-only** (never registered in `Testing` or `Production`) and is
|
||||||
|
idempotent — if the family already exists, the seeder no-ops. To reset, wipe
|
||||||
|
the dev volume (`docker compose -f deploy/docker-compose.dev.yml down -v`)
|
||||||
|
and start over.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Unit + integration tests (xUnit + Testcontainers)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite spins up a throwaway Postgres container per fixture, so the only
|
||||||
|
requirement is a running Docker daemon. ~100 tests, around 30 seconds.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>End-to-end tests (Playwright)</strong> — click to expand</summary>
|
||||||
|
|
||||||
|
Specs live in `web/familynido-web/e2e/` and drive a full stack (API + Postgres
|
||||||
|
+ nginx). To run them locally:
|
||||||
|
|
||||||
|
1. **Seed the two test users.** The API ships an `E2ETestDataSeeder` that only
|
||||||
|
kicks in when the environment is `Testing` and the flag `Seed:E2E:Enabled`
|
||||||
|
is set. Start the API like this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/FamilyNido.Api
|
||||||
|
ASPNETCORE_ENVIRONMENT=Testing \
|
||||||
|
Seed__E2E__Enabled=true \
|
||||||
|
Seed__E2E__UserAPassword=SuperSecret123! \
|
||||||
|
Seed__E2E__UserBPassword=SuperSecret123! \
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the `FamilyNido E2E` family with tester A (admin) and tester B
|
||||||
|
idempotently. The seed lives in its own family row so it never collides
|
||||||
|
with real data.
|
||||||
|
|
||||||
|
2. **Serve the SPA behind nginx** — the specs target port `:5173` by default
|
||||||
|
because `ng serve` doesn't understand the `/es` and `/en` subpaths produced
|
||||||
|
by the i18n build.
|
||||||
|
|
||||||
|
3. **Run Playwright**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/familynido-web
|
||||||
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
|
E2E_USER_EMAIL=e2e-a@familynido.test \
|
||||||
|
E2E_USER_PASSWORD=SuperSecret123! \
|
||||||
|
E2E_USER_B_EMAIL=e2e-b@familynido.test \
|
||||||
|
E2E_USER_B_PASSWORD=SuperSecret123! \
|
||||||
|
npm run e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run e2e:ui` opens Playwright's time-travel UI for debugging.
|
||||||
|
|
||||||
|
In CI, `.github/workflows/e2e.yml` boots the full stack automatically and
|
||||||
|
uploads traces as an artifact whenever a spec fails.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚢 Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp deploy/.env.example deploy/.env
|
||||||
|
# Edit deploy/.env with real values (OIDC, Postgres, VAPID, SMTP, …)
|
||||||
|
docker compose -f deploy/docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The containers (`familynido-api`, `familynido-web`, `familynido-db`) ship with
|
||||||
|
Traefik labels so an external Traefik instance can expose them through your
|
||||||
|
configured `TRAEFIK_HOST`. Refer to [`deploy/README.md`](./deploy/README.md)
|
||||||
|
for first-time server setup, Google Calendar OAuth wiring and rollback
|
||||||
|
procedure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Security
|
||||||
|
|
||||||
|
Reports of vulnerabilities go through GitHub's *Report a vulnerability* button
|
||||||
|
on the **Security** tab — never as a public issue. Full policy and threat
|
||||||
|
model in [`SECURITY.md`](./SECURITY.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT — see [`LICENSE`](./LICENSE).
|
||||||
50
SECURITY.md
Normal file
50
SECURITY.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Security policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
FamilyNido is a single-family self-hosted PWA. Only the `main` branch is
|
||||||
|
supported — there are no released "versions" to back-port fixes to.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
If you find something that looks like a security issue, please **do not**
|
||||||
|
open a public GitHub issue. Instead:
|
||||||
|
|
||||||
|
1. Use GitHub's *Report a vulnerability* button on the **Security** tab of
|
||||||
|
this repository (private disclosure to maintainers).
|
||||||
|
2. Or email the maintainer directly via the address on the GitHub profile
|
||||||
|
linked from the commit history.
|
||||||
|
|
||||||
|
Please include enough detail to reproduce: affected endpoint(s), inputs,
|
||||||
|
expected vs. actual behaviour, and any logs you can share. A short
|
||||||
|
proof-of-concept goes a long way.
|
||||||
|
|
||||||
|
You can expect:
|
||||||
|
|
||||||
|
- An acknowledgement within a few days.
|
||||||
|
- A fix or a clear "won't fix with reason" within two weeks for
|
||||||
|
high-severity issues, longer for low-severity ones.
|
||||||
|
- A public credit in the commit message if you want one.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- DDoS, volumetric or resource-exhaustion attacks against the
|
||||||
|
reference deployment. Rate limiting is in place but the project is not
|
||||||
|
designed to survive large-scale attacks.
|
||||||
|
- Issues that require physical access to the server hosting the instance.
|
||||||
|
- Vulnerabilities in upstream dependencies that already have a published
|
||||||
|
CVE — open a regular issue (or PR with the bump) instead.
|
||||||
|
- Social-engineering or anything that targets an operator rather than the
|
||||||
|
software itself.
|
||||||
|
|
||||||
|
## Threat model in one paragraph
|
||||||
|
|
||||||
|
FamilyNido is intended to run on a home server behind a reverse proxy that
|
||||||
|
terminates TLS (typically Traefik), accessible to the household members
|
||||||
|
through their own browsers and to a handful of integrations (Home
|
||||||
|
Assistant, etc.) through API keys. The realistic attackers we worry about
|
||||||
|
are: a curious neighbour on the LAN, a guest the family briefly granted
|
||||||
|
access to, and the open internet poking at the public URL once the
|
||||||
|
service is exposed. Strong assumptions: the operator controls the host,
|
||||||
|
keeps the OS patched, and runs the `prod.yml` stack instead of editing
|
||||||
|
containers by hand.
|
||||||
55
deploy/.env.example
Normal file
55
deploy/.env.example
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# ─── General ────────────────────────────────────────────────────────────────
|
||||||
|
FAMILYNIDO_BASE_URL=https://familia.example.com
|
||||||
|
FAMILYNIDO_TIMEZONE=Europe/Madrid
|
||||||
|
|
||||||
|
# UID/GID under which the API container will write files. Leave empty to
|
||||||
|
# keep the default in-container user (UID 100, useful with Docker named
|
||||||
|
# volumes). Set to a specific user (typically 1000:1000) when you bind-mount
|
||||||
|
# a host directory and want files to remain owned by that host user — e.g.
|
||||||
|
# /home/dan/familynido/files mapped to /app/data/files.
|
||||||
|
PUID=
|
||||||
|
PGID=
|
||||||
|
|
||||||
|
# ─── GHCR (only consumed by docker-compose.prod.yml) ────────────────────────
|
||||||
|
# GitHub user or org that owns the published images under
|
||||||
|
# ghcr.io/<GHCR_OWNER>/familynido-api and /familynido-web.
|
||||||
|
GHCR_OWNER=your-github-user
|
||||||
|
|
||||||
|
# ─── PostgreSQL ─────────────────────────────────────────────────────────────
|
||||||
|
POSTGRES_DB=familynido
|
||||||
|
POSTGRES_USER=familynido
|
||||||
|
POSTGRES_PASSWORD=change-me-please
|
||||||
|
|
||||||
|
# ─── OIDC (PocketID in prod) ────────────────────────────────────────────────
|
||||||
|
OIDC_AUTHORITY=https://pocketid.example.com
|
||||||
|
OIDC_CLIENT_ID=familynido
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
OIDC_REQUIRE_HTTPS=true
|
||||||
|
|
||||||
|
# ─── Google Calendar (read-only sync) ───────────────────────────────────────
|
||||||
|
# Credentials issued by https://console.cloud.google.com/apis/credentials with
|
||||||
|
# the Calendar API enabled. The redirect URI must match exactly the entry
|
||||||
|
# registered as an "Authorized redirect URI" for the OAuth client.
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_OAUTH_REDIRECT_URI=https://familia.example.com/api/calendar/google/callback
|
||||||
|
|
||||||
|
# ─── Email (SMTP) — used by the invitation flow ─────────────────────────────
|
||||||
|
# Generic SMTP relay. Works with Brevo (formerly Sendinblue), Gmail with an
|
||||||
|
# app password, mailcow, an internal MTA, etc. Leave EMAIL__ENABLED=false to
|
||||||
|
# disable email entirely; admins will still see the "copy link" fallback.
|
||||||
|
EMAIL__ENABLED=false
|
||||||
|
EMAIL__SMTPHOST=smtp-relay.example.com
|
||||||
|
EMAIL__SMTPPORT=587
|
||||||
|
EMAIL__SMTPUSERNAME=
|
||||||
|
EMAIL__SMTPPASSWORD=
|
||||||
|
EMAIL__SMTPUSESTARTTLS=true
|
||||||
|
EMAIL__FROM=FamilyNido <noreply@familia.example.com>
|
||||||
|
EMAIL__APPBASEURL=https://familia.example.com
|
||||||
|
EMAIL__INVITATIONLIFETIME=7.00:00:00
|
||||||
|
|
||||||
|
# ─── Traefik (optional — consumed by labels in docker-compose.prod.yml) ────
|
||||||
|
TRAEFIK_NETWORK=traefik
|
||||||
|
TRAEFIK_ROUTER_NAME=familynido
|
||||||
|
TRAEFIK_HOST=familia.example.com
|
||||||
|
TRAEFIK_TLS_RESOLVER=letsencrypt
|
||||||
117
deploy/README.md
Normal file
117
deploy/README.md
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
Two compose files live here, for two distinct scenarios:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `docker-compose.prod.yml` | Pulls pre-built images from GHCR. This is what runs on the home server. |
|
||||||
|
| `docker-compose.dev.yml` | Dev stack with Postgres only — auth runs on local credentials. |
|
||||||
|
|
||||||
|
Images are built by `.github/workflows/build-and-push.yml` on every push to
|
||||||
|
`main` and published to:
|
||||||
|
|
||||||
|
- `ghcr.io/<GHCR_OWNER>/familynido-api:latest`
|
||||||
|
- `ghcr.io/<GHCR_OWNER>/familynido-web:latest`
|
||||||
|
|
||||||
|
Each push also produces an immutable `sha-<shortsha>` tag for rollbacks.
|
||||||
|
|
||||||
|
## First-time server setup
|
||||||
|
|
||||||
|
Prerequisites on the server (Ubuntu, Docker, Traefik on the
|
||||||
|
`${TRAEFIK_NETWORK}` network with a Let's Encrypt cert resolver).
|
||||||
|
|
||||||
|
1. **Create a Personal Access Token** on GitHub with scope `read:packages`.
|
||||||
|
GHCR is private for private repos, so the server needs to authenticate.
|
||||||
|
|
||||||
|
2. **Log in to GHCR on the server** (one-time, the credentials are cached
|
||||||
|
in `~/.docker/config.json`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo <PAT> | docker login ghcr.io -u <github-username> --password-stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Prepare the deploy folder** (e.g. `/opt/familynido/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/familynido
|
||||||
|
sudo chown $USER:$USER /opt/familynido
|
||||||
|
cd /opt/familynido
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `deploy/docker-compose.prod.yml` and `deploy/.env.example` from
|
||||||
|
this repo into that folder, rename the example to `.env`, and fill it in
|
||||||
|
with the real PocketId/Postgres credentials.
|
||||||
|
|
||||||
|
4. **Pull and start**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Traefik picks the service up via the labels and serves it at the
|
||||||
|
`${TRAEFIK_HOST}` you configured once Let's Encrypt issues the cert.
|
||||||
|
|
||||||
|
> **OIDC client id.** The default `OIDC_CLIENT_ID` is `familynido`. If you
|
||||||
|
> use PocketID or another upstream IdP, create a client with that id (or
|
||||||
|
> change the value here to match what your IdP exposes).
|
||||||
|
|
||||||
|
## Google Calendar credentials
|
||||||
|
|
||||||
|
The calendar module mirrors events from Google Calendar (read-only). The
|
||||||
|
backend needs an OAuth client to drive the consent flow:
|
||||||
|
|
||||||
|
1. Create a project at https://console.cloud.google.com and enable the
|
||||||
|
**Google Calendar API**.
|
||||||
|
2. In **APIs & Services → OAuth consent screen**, configure an external app
|
||||||
|
and add the scope `https://www.googleapis.com/auth/calendar.readonly`.
|
||||||
|
Point the privacy/terms URLs at the pages your deployment serves under
|
||||||
|
`/legal/privacidad.html` and `/legal/condiciones.html`. Both live in
|
||||||
|
`web/familynido-web/public/legal/` in the repo and are deployed
|
||||||
|
automatically with the web image.
|
||||||
|
3. In **APIs & Services → Credentials**, create an **OAuth 2.0 Client ID** of
|
||||||
|
type *Web application*. Set the authorized redirect URI to exactly
|
||||||
|
`https://<your-host>/api/calendar/google/callback`.
|
||||||
|
4. Copy the client id and secret into `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GOOGLE_CLIENT_ID=...
|
||||||
|
GOOGLE_CLIENT_SECRET=...
|
||||||
|
GOOGLE_OAUTH_REDIRECT_URI=https://<your-host>/api/calendar/google/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Restart the API container so the new env vars are picked up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --force-recreate api
|
||||||
|
```
|
||||||
|
|
||||||
|
The callback path is already proxied by the existing `/api/` block in
|
||||||
|
`deploy/nginx/default.conf`, so no extra reverse-proxy config is needed.
|
||||||
|
|
||||||
|
## Updating after a push to `main`
|
||||||
|
|
||||||
|
Until Watchtower is wired up (separate commit), updates are a manual pull:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/familynido
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rolling back
|
||||||
|
|
||||||
|
Each build tags the images with `sha-<shortsha>`. To roll back to a previous
|
||||||
|
build without reverting the main branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Pin the images to a known-good SHA in docker-compose.prod.yml,
|
||||||
|
# e.g. ghcr.io/.../familynido-api:sha-a57d52e
|
||||||
|
# 2. docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database migrations
|
||||||
|
|
||||||
|
Not yet automated. After a deploy that includes a new EF migration, run
|
||||||
|
them manually from the repo on the dev machine pointed at the prod DB, or
|
||||||
|
(TODO) add `db.Database.Migrate()` to `Program.cs` gated by config.
|
||||||
46
deploy/api-entrypoint.sh
Normal file
46
deploy/api-entrypoint.sh
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Entrypoint for the FamilyNido API container.
|
||||||
|
#
|
||||||
|
# Runs briefly as root, normalizes ownership of the writable mount points
|
||||||
|
# and drops privileges via `su-exec`. Two scenarios are supported:
|
||||||
|
#
|
||||||
|
# 1. Docker named volume (default in our docker-compose.prod.yml). The
|
||||||
|
# mount point is created as root on first start; we chown it to the
|
||||||
|
# `familynido` user that the image ships with (UID 100, GID 101 on
|
||||||
|
# Alpine).
|
||||||
|
#
|
||||||
|
# 2. Host bind mount (e.g. `/home/dan/.../files:/app/data/files`). The
|
||||||
|
# operator likely wants the files to remain owned by their host user
|
||||||
|
# so they can browse/back them up without sudo. To support that, set
|
||||||
|
# PUID/PGID env vars to the host user's IDs (commonly 1000:1000); the
|
||||||
|
# script recreates the in-container `familynido` user with those IDs
|
||||||
|
# before chowning.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CURRENT_UID=$(id -u familynido)
|
||||||
|
CURRENT_GID=$(id -g familynido)
|
||||||
|
TARGET_UID="${PUID:-$CURRENT_UID}"
|
||||||
|
TARGET_GID="${PGID:-$CURRENT_GID}"
|
||||||
|
|
||||||
|
if [ "$TARGET_UID" != "$CURRENT_UID" ] || [ "$TARGET_GID" != "$CURRENT_GID" ]; then
|
||||||
|
# Drop the existing user/group and recreate them with the requested IDs.
|
||||||
|
deluser familynido 2>/dev/null || true
|
||||||
|
delgroup familynido 2>/dev/null || true
|
||||||
|
addgroup -g "$TARGET_GID" familynido
|
||||||
|
adduser -u "$TARGET_UID" -G familynido -h /home/familynido -D familynido
|
||||||
|
|
||||||
|
# Re-stamp the published .NET binaries and the home dir so the new
|
||||||
|
# familynido (different UID/GID) can read everything.
|
||||||
|
chown -R familynido:familynido /app /home/familynido
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Idempotent ownership normalization for the writable mount points. Works
|
||||||
|
# for both named volumes (start out root:root) and bind mounts (whatever
|
||||||
|
# the host owner is).
|
||||||
|
for dir in /app/data/files /home/familynido/.aspnet/DataProtection-Keys; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
chown -R familynido:familynido "$dir"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
exec su-exec familynido:familynido "$@"
|
||||||
64
deploy/api.Dockerfile
Normal file
64
deploy/api.Dockerfile
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# FamilyNido backend — .NET 10 ASP.NET Core Minimal API.
|
||||||
|
# Multi-stage: restore+publish on the SDK, run on the ASP.NET runtime.
|
||||||
|
|
||||||
|
ARG DOTNET_VERSION=10.0
|
||||||
|
|
||||||
|
# ─── Build stage ────────────────────────────────────────────────────────────
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy just the csproj/solution files first to cache the restore layer.
|
||||||
|
COPY Directory.Build.props global.json ./
|
||||||
|
COPY src/FamilyNido.Api/FamilyNido.Api.csproj src/FamilyNido.Api/
|
||||||
|
COPY src/FamilyNido.Domain/FamilyNido.Domain.csproj src/FamilyNido.Domain/
|
||||||
|
COPY src/FamilyNido.Persistence/FamilyNido.Persistence.csproj src/FamilyNido.Persistence/
|
||||||
|
COPY tests/FamilyNido.Tests/FamilyNido.Tests.csproj tests/FamilyNido.Tests/
|
||||||
|
RUN dotnet restore src/FamilyNido.Api/FamilyNido.Api.csproj
|
||||||
|
|
||||||
|
# Now copy the sources and publish a self-contained trim-free release.
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet publish src/FamilyNido.Api/FamilyNido.Api.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--no-restore \
|
||||||
|
--output /app/publish \
|
||||||
|
/p:UseAppHost=false
|
||||||
|
|
||||||
|
# ─── Runtime stage ──────────────────────────────────────────────────────────
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Non-root user to follow least-privilege. Pre-create the writable
|
||||||
|
# directories with the right owner so that a Docker volume mounted on them
|
||||||
|
# at runtime inherits the permissions on first creation.
|
||||||
|
RUN addgroup -S familynido && adduser -S -G familynido familynido \
|
||||||
|
&& mkdir -p /app/data/files /home/familynido/.aspnet/DataProtection-Keys \
|
||||||
|
&& chown -R familynido:familynido /app /home/familynido
|
||||||
|
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
ASPNETCORE_HTTP_PORTS=8080 \
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER=true \
|
||||||
|
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||||
|
|
||||||
|
# ICU + tzdata so DateTimeOffset.ToLocalTime() and es-ES formatting work.
|
||||||
|
# `su-exec` lets the entrypoint drop privileges after the chown step —
|
||||||
|
# alpine ships it as a tiny ~10 KB binary equivalent to gosu.
|
||||||
|
RUN apk add --no-cache icu-libs icu-data-full tzdata su-exec
|
||||||
|
|
||||||
|
COPY --from=build --chown=familynido:familynido /app/publish .
|
||||||
|
COPY --chown=root:root deploy/api-entrypoint.sh /usr/local/bin/api-entrypoint.sh
|
||||||
|
# sed strips any CRLF line endings introduced by Windows checkouts so the
|
||||||
|
# shebang and statements parse correctly on the Alpine /bin/sh.
|
||||||
|
RUN sed -i 's/\r$//' /usr/local/bin/api-entrypoint.sh \
|
||||||
|
&& chmod +x /usr/local/bin/api-entrypoint.sh
|
||||||
|
|
||||||
|
# Note: do NOT set USER familynido here. The entrypoint starts as root,
|
||||||
|
# normalizes ownership of the volume mount points, and then exec-s into the
|
||||||
|
# .NET host as `familynido` via su-exec.
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:8080/health/live || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/api-entrypoint.sh"]
|
||||||
|
CMD ["dotnet", "FamilyNido.Api.dll"]
|
||||||
31
deploy/docker-compose.dev.yml
Normal file
31
deploy/docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# FamilyNido — development compose stack.
|
||||||
|
# Brings up PostgreSQL so you can develop the Angular app (ng serve on :4200)
|
||||||
|
# and the .NET backend (dotnet watch on :5080) against a real database.
|
||||||
|
#
|
||||||
|
# Auth in dev is handled by local credentials only (POST /api/auth/local/login)
|
||||||
|
# — the OIDC code path is for production with PocketID and is hidden in the
|
||||||
|
# login UI when /api/auth/providers reports oidcEnabled=false.
|
||||||
|
#
|
||||||
|
# docker compose -f deploy/docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: familynido-db-dev
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: familynido
|
||||||
|
POSTGRES_USER: familynido
|
||||||
|
POSTGRES_PASSWORD: familynido
|
||||||
|
ports:
|
||||||
|
- "5435:5432"
|
||||||
|
volumes:
|
||||||
|
- familynido-db-dev-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U familynido -d familynido"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
familynido-db-dev-data:
|
||||||
108
deploy/docker-compose.prod.yml
Normal file
108
deploy/docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# FamilyNido — production compose stack for the home Ubuntu server.
|
||||||
|
# Pulls pre-built images from GHCR (published by .github/workflows/build-and-push.yml)
|
||||||
|
# instead of building on the host. Run from /opt/familynido/ (or wherever this
|
||||||
|
# file lives alongside its .env) with:
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.prod.yml pull
|
||||||
|
# docker compose -f docker-compose.prod.yml up -d
|
||||||
|
#
|
||||||
|
# Assumes an external Traefik instance terminates TLS via the configured
|
||||||
|
# cert resolver on the ${TRAEFIK_NETWORK} network.
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: familynido-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
TZ: ${FAMILYNIDO_TIMEZONE}
|
||||||
|
volumes:
|
||||||
|
- familynido-db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: ghcr.io/${GHCR_OWNER}/familynido-api:latest
|
||||||
|
container_name: familynido-api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# PUID/PGID let the entrypoint align the in-container `familynido` user
|
||||||
|
# with the host owner of any bind-mounted directory. Leave the .env
|
||||||
|
# values empty to keep the default (system UID 100). Set them to a
|
||||||
|
# specific UID/GID (e.g. 1000:1000) when the files volume is a bind
|
||||||
|
# mount and you want the writes to appear under that host user.
|
||||||
|
PUID: ${PUID:-}
|
||||||
|
PGID: ${PGID:-}
|
||||||
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
|
ASPNETCORE_HTTP_PORTS: "8080"
|
||||||
|
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
|
||||||
|
ConnectionStrings__Postgres: Host=db;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
|
||||||
|
Oidc__Authority: ${OIDC_AUTHORITY}
|
||||||
|
Oidc__ClientId: ${OIDC_CLIENT_ID}
|
||||||
|
Oidc__ClientSecret: ${OIDC_CLIENT_SECRET}
|
||||||
|
Oidc__RequireHttpsMetadata: ${OIDC_REQUIRE_HTTPS}
|
||||||
|
Family__DefaultTimeZone: ${FAMILYNIDO_TIMEZONE}
|
||||||
|
Cors__AllowedOrigins__0: ${FAMILYNIDO_BASE_URL}
|
||||||
|
Calendar__GoogleClientId: ${GOOGLE_CLIENT_ID}
|
||||||
|
Calendar__GoogleClientSecret: ${GOOGLE_CLIENT_SECRET}
|
||||||
|
Calendar__OAuthRedirectUri: ${GOOGLE_OAUTH_REDIRECT_URI}
|
||||||
|
Email__Enabled: ${EMAIL__ENABLED:-false}
|
||||||
|
Email__SmtpHost: ${EMAIL__SMTPHOST:-}
|
||||||
|
Email__SmtpPort: ${EMAIL__SMTPPORT:-587}
|
||||||
|
Email__SmtpUsername: ${EMAIL__SMTPUSERNAME:-}
|
||||||
|
Email__SmtpPassword: ${EMAIL__SMTPPASSWORD:-}
|
||||||
|
Email__SmtpUseStartTls: ${EMAIL__SMTPUSESTARTTLS:-true}
|
||||||
|
Email__From: ${EMAIL__FROM:-FamilyNido <noreply@familynido.example.com>}
|
||||||
|
Email__AppBaseUrl: ${EMAIL__APPBASEURL:-${FAMILYNIDO_BASE_URL}}
|
||||||
|
Email__InvitationLifetime: ${EMAIL__INVITATIONLIFETIME:-7.00:00:00}
|
||||||
|
TZ: ${FAMILYNIDO_TIMEZONE}
|
||||||
|
volumes:
|
||||||
|
- familynido-files:/app/data/files
|
||||||
|
# Persist ASP.NET Core Data Protection keys across container recreations.
|
||||||
|
# Without this, every `docker compose up -d` rotates the keys and all
|
||||||
|
# active OIDC cookies (session + nonce/correlation) become unreadable.
|
||||||
|
- familynido-keys:/home/familynido/.aspnet/DataProtection-Keys
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
labels:
|
||||||
|
- com.centurylinklabs.watchtower.enable=true
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: ghcr.io/${GHCR_OWNER}/familynido-web:latest
|
||||||
|
container_name: familynido-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- com.centurylinklabs.watchtower.enable=true
|
||||||
|
- traefik.docker.network=${TRAEFIK_NETWORK}
|
||||||
|
- traefik.http.routers.${TRAEFIK_ROUTER_NAME}.rule=Host(`${TRAEFIK_HOST}`)
|
||||||
|
- traefik.http.routers.${TRAEFIK_ROUTER_NAME}.tls=true
|
||||||
|
- traefik.http.routers.${TRAEFIK_ROUTER_NAME}.tls.certresolver=${TRAEFIK_TLS_RESOLVER}
|
||||||
|
- traefik.http.services.${TRAEFIK_ROUTER_NAME}.loadbalancer.server.port=8080
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
familynido-db-data:
|
||||||
|
familynido-files:
|
||||||
|
familynido-keys:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
traefik:
|
||||||
|
name: ${TRAEFIK_NETWORK}
|
||||||
|
external: true
|
||||||
135
deploy/nginx/default.conf
Normal file
135
deploy/nginx/default.conf
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
listen [::]:8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# We sit behind Traefik on a different scheme/port. nginx defaults to
|
||||||
|
# `absolute_redirect on`, which would rewrite a `return 302 /es/;` to an
|
||||||
|
# absolute `http://<server_name>:8080/es/` — and the browser would then
|
||||||
|
# follow that to the internal port, bypassing the proxy.
|
||||||
|
# Sending only the path lets the browser resolve it against the public
|
||||||
|
# URL it actually used (https://familia.example.com/...).
|
||||||
|
absolute_redirect off;
|
||||||
|
|
||||||
|
# Bigger proxy buffers for the API responses. The /signin-oidc 302 returns
|
||||||
|
# a session cookie chunked across `familynido.session*` cookies, easily
|
||||||
|
# 6–10 KB of headers. With the default 4–8 KB buffer nginx would abort
|
||||||
|
# with `upstream sent too big header` and surface a 502.
|
||||||
|
proxy_buffer_size 16k;
|
||||||
|
proxy_buffers 8 16k;
|
||||||
|
proxy_busy_buffers_size 32k;
|
||||||
|
|
||||||
|
# Bigger *request* header buffers. The OIDC callback receives a long
|
||||||
|
# `?state=...` plus the browser's Cookie header (session + correlation
|
||||||
|
# cookies + sometimes orphan nonces from prior failed attempts). The
|
||||||
|
# default 4×8K trips the moment any of those grow, returning a stark
|
||||||
|
# 400 "Request Header Or Cookie Too Large" before the request even
|
||||||
|
# reaches the backend.
|
||||||
|
large_client_header_buffers 8 32k;
|
||||||
|
|
||||||
|
# Allow image uploads up to ~12 MB. The API enforces its own 10 MB cap
|
||||||
|
# (FilesOptions.MaxImageBytes); we pad slightly so a borderline file gets
|
||||||
|
# nginx out of the way and surfaces the API's clean validation error
|
||||||
|
# instead of a blunt 413 with no body. iOS HEIC photos routinely sit
|
||||||
|
# in the 3–8 MB range and would otherwise fail silently.
|
||||||
|
client_max_body_size 12m;
|
||||||
|
|
||||||
|
# Gzip text assets; PWA manifest explicitly included.
|
||||||
|
gzip on;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json
|
||||||
|
application/manifest+json image/svg+xml text/xml;
|
||||||
|
|
||||||
|
# Aggressive caching for hashed bundle files (Angular outputHashing=all).
|
||||||
|
location ~* \.(?:js|css|woff2?|ttf|otf|eot|ico|svg|png|jpg|jpeg|gif|webp|avif)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Service worker registration script and config must never be cached.
|
||||||
|
location ~* (ngsw-worker\.js|ngsw\.json|safety-worker\.js|worker-basic\.min\.js|manifest\.webmanifest)$ {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pass /api through to the backend container (name "api" in compose).
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# Preserve the scheme Traefik announced to the outside world
|
||||||
|
# (https) instead of overwriting it with nginx's own $scheme (http).
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OIDC callback paths handled by the backend. Need the same forwarded
|
||||||
|
# headers as /api/ so the OIDC middleware validates the callback against
|
||||||
|
# the public host/scheme and the AuthenticationProperties round-trip works.
|
||||||
|
location = /signin-oidc {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
location = /signout-callback-oidc {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
return 200 "ok\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Locale routing (i18n subpath layout) ────────────────────────────────
|
||||||
|
# Build emits two bundles: dist/.../browser/es/ and dist/.../browser/en/.
|
||||||
|
# The Dockerfile copies the entire `browser/` tree to /usr/share/nginx/html
|
||||||
|
# so the container ends up with /usr/share/nginx/html/{es,en}/index.html.
|
||||||
|
#
|
||||||
|
# Root visit: pick es or en from Accept-Language and redirect. Anyone
|
||||||
|
# bookmarked at /es/... or /en/... hits the SPA below directly.
|
||||||
|
|
||||||
|
location = / {
|
||||||
|
# Negotiate locale from Accept-Language. Defaults to es-ES (the
|
||||||
|
# source locale) for any browser that doesn't explicitly prefer
|
||||||
|
# English. Add more `if` branches as locales grow.
|
||||||
|
if ($http_accept_language ~* "^en") {
|
||||||
|
return 302 /en/;
|
||||||
|
}
|
||||||
|
return 302 /es/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback per locale: any unknown path under /es/ or /en/ falls
|
||||||
|
# through to that bundle's index.html so the Angular router takes over.
|
||||||
|
location /es/ {
|
||||||
|
try_files $uri $uri/ /es/index.html;
|
||||||
|
}
|
||||||
|
location /en/ {
|
||||||
|
try_files $uri $uri/ /en/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Catch-all for unprefixed paths. Anything we didn't match above (and
|
||||||
|
# that isn't /api/ or one of the OIDC callbacks) is a stray client
|
||||||
|
# request — typically a stale `returnUrl` like `/home` from before the
|
||||||
|
# /es//en/ split. Redirect it through to the Spanish bundle so the
|
||||||
|
# SPA can take over instead of nginx returning its default 404.
|
||||||
|
location / {
|
||||||
|
if ($http_accept_language ~* "^en") { return 302 /en$request_uri; }
|
||||||
|
return 302 /es$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
deploy/web.Dockerfile
Normal file
32
deploy/web.Dockerfile
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# FamilyNido frontend — Angular 21 PWA served by nginx.
|
||||||
|
# Multi-stage: build bundle with Node, then ship static files on nginx:alpine.
|
||||||
|
|
||||||
|
ARG NODE_VERSION=22
|
||||||
|
ARG NGINX_VERSION=1.27
|
||||||
|
|
||||||
|
# ─── Build stage ────────────────────────────────────────────────────────────
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY web/familynido-web/package.json web/familynido-web/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY web/familynido-web/ ./
|
||||||
|
RUN npx ng build --configuration production
|
||||||
|
|
||||||
|
# ─── Runtime stage ──────────────────────────────────────────────────────────
|
||||||
|
FROM nginx:${NGINX_VERSION}-alpine AS runtime
|
||||||
|
|
||||||
|
# Non-root nginx worker.
|
||||||
|
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist/familynido-web/browser/ /usr/share/nginx/html/
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:8080/healthz || exit 1
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
66
docs/integrations/README.md
Normal file
66
docs/integrations/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Integrations — FamilyNido public API
|
||||||
|
|
||||||
|
FamilyNido exposes a small set of versioned endpoints under `/api/v1/**`
|
||||||
|
so any external integration (an automation runner, n8n, IFTTT, a cron
|
||||||
|
job on another machine…) can create tasks in the family's dashboard.
|
||||||
|
|
||||||
|
Authentication is by **API key**, minted from the FamilyNido admin UI
|
||||||
|
(*My account → API keys for integrations*, admins only). The key is
|
||||||
|
sent with every request:
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Api-Key: bxn_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
or, alternatively, as `Authorization: Bearer bxn_…`.
|
||||||
|
|
||||||
|
## Available endpoints
|
||||||
|
|
||||||
|
### `POST /api/v1/tasks` — create a task
|
||||||
|
|
||||||
|
Body (JSON):
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Notes |
|
||||||
|
| - | - | - | - | - |
|
||||||
|
| `title` | string | yes | — | Up to 200 characters. |
|
||||||
|
| `category` | string | no | `"General"` | Up to 50 characters. |
|
||||||
|
| `points` | int | no | `5` | Range 0–100. |
|
||||||
|
| `dueDate` | string `YYYY-MM-DD` | no | today | Ignored when `isFloating=true`. |
|
||||||
|
| `isFloating` | bool | no | `false` | Floating task — has no fixed date and stays pending every day until someone marks it done. |
|
||||||
|
| `responsibleMemberId` | guid | no | — | Must belong to the family the token was issued for. |
|
||||||
|
| `deduplicate` | bool | no | `false` | When `true` and a pending task (not archived, no completions) with the same `title` already exists, returns `200 OK` with `created=false` instead of inserting a new row. |
|
||||||
|
|
||||||
|
Response `201 Created` (or `200 OK` when `deduplicate` matches an
|
||||||
|
existing pending task):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"created": true,
|
||||||
|
"reason": null,
|
||||||
|
"taskId": "01938efc-...",
|
||||||
|
"title": "Empty the dishwasher"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`reason` is `"already-pending"` on a dedup hit.
|
||||||
|
|
||||||
|
Common errors:
|
||||||
|
|
||||||
|
- `400 Bad Request`: validation failed (empty title, `points` out of
|
||||||
|
range, `responsibleMemberId` outside the family).
|
||||||
|
- `401 Unauthorized`: header missing, malformed or revoked.
|
||||||
|
- `429 Too Many Requests`: rate limit exceeded (60 req/min/IP).
|
||||||
|
|
||||||
|
### Example: `curl`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-host/api/v1/tasks \
|
||||||
|
-H "X-Api-Key: bxn_xxxxxxxx…" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"Buy bread","category":"Shopping","points":1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concrete guides
|
||||||
|
|
||||||
|
If you wire up an integration worth sharing, open a PR against this
|
||||||
|
folder with a short worked example — that kind of doc pays for itself.
|
||||||
34
docs/screenshots/README.md
Normal file
34
docs/screenshots/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Screenshots
|
||||||
|
|
||||||
|
This folder hosts the images embedded in the main [`README.md`](../../README.md).
|
||||||
|
Drop the following PNGs in here and the README will render them automatically.
|
||||||
|
|
||||||
|
## Recommended capture flow
|
||||||
|
|
||||||
|
To avoid screenshotting real family data, use the **demo seed** built into the
|
||||||
|
API (see the *"Seed a curated demo family"* section in the root README). It
|
||||||
|
drops a fictitious Smith family with tasks, a wall thread, meal plan, school
|
||||||
|
schedule and adults' weekly agenda — everything you need to fill the three
|
||||||
|
captures below without touching personal data. Reset between attempts with
|
||||||
|
`docker compose -f deploy/docker-compose.dev.yml down -v`.
|
||||||
|
|
||||||
|
| File | Used in section | Suggested capture |
|
||||||
|
| ------------------------ | -------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `hero-dashboard.png` | Lead (before Highlights) | The Home dashboard at desktop width with a few widgets populated. |
|
||||||
|
| `tablet-mode.png` | 📺 Tablet mode | The full-screen rotating tablet view, ideally landscape 16:9. |
|
||||||
|
| `wall.png` | 💬 Wall | A wall post with a reaction or comment so the social bits are visible. |
|
||||||
|
| `meals.png` | 🍽️ Meals | The weekly meal-plan grid with at least one full week filled in. |
|
||||||
|
|
||||||
|
## Capture tips
|
||||||
|
|
||||||
|
- **Width**: aim for 1600–1920 px wide on desktop captures, 1280 px wide on
|
||||||
|
the tablet view. GitHub will downscale gracefully.
|
||||||
|
- **Format**: PNG with the browser chrome cropped out. JPEG only if the
|
||||||
|
capture has photo backgrounds and PNG is heavy.
|
||||||
|
- **Privacy**: scrub any real family data before committing — use the
|
||||||
|
`FamilyNido E2E` seed accounts or invent obviously-fake names.
|
||||||
|
- **Light/dark**: pick whichever feels more polished; mixing both in the same
|
||||||
|
README is fine if you label them.
|
||||||
|
|
||||||
|
Files in this folder that are not referenced from the README can be removed
|
||||||
|
without affecting anything.
|
||||||
BIN
docs/screenshots/hero-dashboard.png
Normal file
BIN
docs/screenshots/hero-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
BIN
docs/screenshots/meals.png
Normal file
BIN
docs/screenshots/meals.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/screenshots/tablet-mode.png
Normal file
BIN
docs/screenshots/tablet-mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
BIN
docs/screenshots/wall.png
Normal file
BIN
docs/screenshots/wall.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
6
global.json
Normal file
6
global.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.102",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
723
src/FamilyNido.Api/Bootstrap/DemoDataSeeder.cs
Normal file
723
src/FamilyNido.Api/Bootstrap/DemoDataSeeder.cs
Normal file
|
|
@ -0,0 +1,723 @@
|
||||||
|
using FamilyNido.Api.Shared.Markdown;
|
||||||
|
using FamilyNido.Domain.Agenda;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using FamilyNido.Domain.HouseholdTasks;
|
||||||
|
using FamilyNido.Domain.Identity;
|
||||||
|
using FamilyNido.Domain.Meals;
|
||||||
|
using FamilyNido.Domain.School;
|
||||||
|
using FamilyNido.Domain.Wall;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Bootstrap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hosted service that drops a curated, screenshot-friendly scenario into an
|
||||||
|
/// empty database when running under the <c>Development</c> environment with
|
||||||
|
/// <c>Seed:Demo:Enabled=true</c>. Exists so the README screenshots can be
|
||||||
|
/// captured against a coherent fake family without anybody having to expose
|
||||||
|
/// their real data.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Idempotent at the family level: if a family with the configured name
|
||||||
|
/// already exists, the seeder bails. Re-seeding from scratch is therefore a
|
||||||
|
/// matter of recreating the dev volume
|
||||||
|
/// (<c>docker compose -f deploy/docker-compose.dev.yml down -v && up -d</c>)
|
||||||
|
/// and restarting the API.</para>
|
||||||
|
/// <para>The seeder anchors every date to "today" in the family's configured
|
||||||
|
/// time zone so the captured screenshots feel current regardless of when they
|
||||||
|
/// are taken — tasks pending today are actually pending today, completions
|
||||||
|
/// from the past week truly fall in the past week, etc.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DemoDataSeeder : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly DemoSeedOptions _options;
|
||||||
|
private readonly ILogger<DemoDataSeeder> _logger;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public DemoDataSeeder(
|
||||||
|
IServiceProvider services,
|
||||||
|
IOptions<DemoSeedOptions> options,
|
||||||
|
ILogger<DemoDataSeeder> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_options.AdminPassword))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Demo seeder enabled but Seed:Demo:AdminPassword is empty — skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var hasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<User>>();
|
||||||
|
var markdown = scope.ServiceProvider.GetRequiredService<MarkdownRenderer>();
|
||||||
|
|
||||||
|
var alreadySeeded = await db.Families
|
||||||
|
.AnyAsync(f => f.Name == _options.FamilyName, cancellationToken);
|
||||||
|
if (alreadySeeded)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Demo seeder: family {FamilyName} already exists — skipping seed.",
|
||||||
|
_options.FamilyName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor every relative date to "today" in the family's timezone so the
|
||||||
|
// screenshots stay coherent regardless of where they are captured.
|
||||||
|
var tz = TimeZoneInfo.FindSystemTimeZoneById(_options.TimeZone);
|
||||||
|
var todayLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz);
|
||||||
|
var today = DateOnly.FromDateTime(todayLocal.DateTime);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Demo seeder: bootstrapping family {FamilyName} (today={Today}, tz={TimeZone}).",
|
||||||
|
_options.FamilyName, today, _options.TimeZone);
|
||||||
|
|
||||||
|
var family = await SeedFamilyAsync(db, cancellationToken);
|
||||||
|
var members = await SeedMembersAsync(db, hasher, family, today, cancellationToken);
|
||||||
|
await SeedTasksAsync(db, family, members, today, cancellationToken);
|
||||||
|
await SeedWallAsync(db, markdown, family, members, cancellationToken);
|
||||||
|
await SeedMealsAsync(db, family, today, cancellationToken);
|
||||||
|
await SeedSchoolAsync(db, family, members, today, cancellationToken);
|
||||||
|
await SeedAgendaAsync(db, family, members, cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Demo seeder: done. Log in as {AdminEmail} to browse.",
|
||||||
|
_options.AdminEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Family> SeedFamilyAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var family = new Family
|
||||||
|
{
|
||||||
|
Name = _options.FamilyName,
|
||||||
|
TimeZone = _options.TimeZone,
|
||||||
|
Locale = _options.Locale,
|
||||||
|
Latitude = _options.Latitude,
|
||||||
|
Longitude = _options.Longitude,
|
||||||
|
LocationLabel = _options.LocationLabel,
|
||||||
|
};
|
||||||
|
db.Families.Add(family);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return family;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DemoMembers> SeedMembersAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
IPasswordHasher<User> hasher,
|
||||||
|
Family family,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var dan = await CreateAdultAsync(
|
||||||
|
db, hasher, family,
|
||||||
|
_options.AdminEmail, _options.AdminDisplayName, _options.AdminPassword,
|
||||||
|
colorHex: "#3B82F6", role: FamilyRole.Admin, cancellationToken);
|
||||||
|
|
||||||
|
FamilyMember eve;
|
||||||
|
if (!string.IsNullOrEmpty(_options.PartnerPassword))
|
||||||
|
{
|
||||||
|
eve = await CreateAdultAsync(
|
||||||
|
db, hasher, family,
|
||||||
|
_options.PartnerEmail, _options.PartnerDisplayName, _options.PartnerPassword,
|
||||||
|
colorHex: "#10B981", role: FamilyRole.Adult, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
eve = new FamilyMember
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
DisplayName = _options.PartnerDisplayName,
|
||||||
|
MemberType = MemberType.Adult,
|
||||||
|
ColorHex = "#10B981",
|
||||||
|
ContactEmail = _options.PartnerEmail,
|
||||||
|
};
|
||||||
|
db.FamilyMembers.Add(eve);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kids: derive credible ages from "today" so the screenshots look
|
||||||
|
// consistent independently of the calendar year.
|
||||||
|
var alice = new FamilyMember
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
DisplayName = "Alice",
|
||||||
|
MemberType = MemberType.Child,
|
||||||
|
ColorHex = "#EC4899",
|
||||||
|
BirthDate = today.AddYears(-9).AddMonths(-2),
|
||||||
|
};
|
||||||
|
var bob = new FamilyMember
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
DisplayName = "Bob",
|
||||||
|
MemberType = MemberType.Child,
|
||||||
|
ColorHex = "#F59E0B",
|
||||||
|
BirthDate = today.AddYears(-6).AddMonths(-4),
|
||||||
|
};
|
||||||
|
db.FamilyMembers.AddRange(alice, bob);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new DemoMembers(dan, eve, alice, bob);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FamilyMember> CreateAdultAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
IPasswordHasher<User> hasher,
|
||||||
|
Family family,
|
||||||
|
string email,
|
||||||
|
string displayName,
|
||||||
|
string password,
|
||||||
|
string colorHex,
|
||||||
|
FamilyRole role,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
DisplayName = displayName,
|
||||||
|
Role = role,
|
||||||
|
PreferredLanguage = _options.PreferredLanguage,
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
db.UserCredentials.Add(new UserCredential
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
Provider = IdentityProvider.Local,
|
||||||
|
PasswordHash = hasher.HashPassword(user, password),
|
||||||
|
});
|
||||||
|
|
||||||
|
var member = new FamilyMember
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
DisplayName = displayName,
|
||||||
|
MemberType = MemberType.Adult,
|
||||||
|
ColorHex = colorHex,
|
||||||
|
ContactEmail = email,
|
||||||
|
UserId = user.Id,
|
||||||
|
};
|
||||||
|
db.FamilyMembers.Add(member);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedTasksAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
Family family,
|
||||||
|
DemoMembers m,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Mix of recurring + single-shot + floating so each tab on the tasks
|
||||||
|
// page has visible content, plus a tail of past completions that feeds
|
||||||
|
// the scoreboard widget.
|
||||||
|
var trash = new HouseholdTask
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Title = "Take out the trash",
|
||||||
|
Category = "Home",
|
||||||
|
Recurrence = RecurrenceMode.Daily,
|
||||||
|
StartDate = today.AddDays(-14),
|
||||||
|
Points = 2,
|
||||||
|
CreatedByMemberId = m.Dan.Id,
|
||||||
|
};
|
||||||
|
var dishwasher = new HouseholdTask
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Title = "Empty the dishwasher",
|
||||||
|
Category = "Kitchen",
|
||||||
|
Recurrence = RecurrenceMode.Daily,
|
||||||
|
StartDate = today.AddDays(-14),
|
||||||
|
Points = 3,
|
||||||
|
CreatedByMemberId = m.Eve.Id,
|
||||||
|
};
|
||||||
|
var vacuum = new HouseholdTask
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Title = "Vacuum the living room",
|
||||||
|
Category = "Cleaning",
|
||||||
|
Recurrence = RecurrenceMode.Weekly,
|
||||||
|
WeeklyDays = DayOfWeekMask.Saturday,
|
||||||
|
StartDate = today.AddDays(-30),
|
||||||
|
Points = 5,
|
||||||
|
CreatedByMemberId = m.Dan.Id,
|
||||||
|
};
|
||||||
|
var plants = new HouseholdTask
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Title = "Water the plants",
|
||||||
|
Category = "Home",
|
||||||
|
Recurrence = RecurrenceMode.Weekly,
|
||||||
|
WeeklyDays = DayOfWeekMask.Wednesday | DayOfWeekMask.Saturday,
|
||||||
|
StartDate = today.AddDays(-30),
|
||||||
|
Points = 2,
|
||||||
|
CreatedByMemberId = m.Eve.Id,
|
||||||
|
};
|
||||||
|
var bathroom = new HouseholdTask
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Title = "Clean the bathroom",
|
||||||
|
Category = "Cleaning",
|
||||||
|
Recurrence = RecurrenceMode.Weekly,
|
||||||
|
WeeklyDays = DayOfWeekMask.Sunday,
|
||||||
|
StartDate = today.AddDays(-30),
|
||||||
|
Points = 5,
|
||||||
|
CreatedByMemberId = m.Dan.Id,
|
||||||
|
};
|
||||||
|
var rent = new HouseholdTask
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Title = "Pay the rent",
|
||||||
|
Category = "Bills",
|
||||||
|
Recurrence = RecurrenceMode.Monthly,
|
||||||
|
MonthlyDay = 1,
|
||||||
|
StartDate = new DateOnly(today.Year, today.Month, 1).AddMonths(-3),
|
||||||
|
Points = 5,
|
||||||
|
CreatedByMemberId = m.Dan.Id,
|
||||||
|
};
|
||||||
|
var ballet = new HouseholdTask
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Title = "Pick up Alice from ballet",
|
||||||
|
Category = "Kids",
|
||||||
|
Recurrence = RecurrenceMode.None,
|
||||||
|
StartDate = today,
|
||||||
|
DueDate = today,
|
||||||
|
Points = 3,
|
||||||
|
CreatedByMemberId = m.Eve.Id,
|
||||||
|
ResponsibleMemberId = m.Eve.Id,
|
||||||
|
};
|
||||||
|
var bread = new HouseholdTask
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Title = "Buy bread on the way home",
|
||||||
|
Category = "Errands",
|
||||||
|
Recurrence = RecurrenceMode.None,
|
||||||
|
StartDate = today,
|
||||||
|
IsFloating = true,
|
||||||
|
Points = 1,
|
||||||
|
CreatedByMemberId = m.Dan.Id,
|
||||||
|
};
|
||||||
|
var grandma = new HouseholdTask
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Title = "Call grandma",
|
||||||
|
Category = "Family",
|
||||||
|
Recurrence = RecurrenceMode.None,
|
||||||
|
StartDate = today,
|
||||||
|
DueDate = today.AddDays(1),
|
||||||
|
Points = 2,
|
||||||
|
CreatedByMemberId = m.Eve.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.HouseholdTasks.AddRange(trash, dishwasher, vacuum, plants, bathroom, rent, ballet, bread, grandma);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Past completions sprinkled across the last two weeks so the
|
||||||
|
// scoreboard has data and the per-task history feels lived-in.
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var completions = new List<TaskCompletion>
|
||||||
|
{
|
||||||
|
// Dan is on the trash this week.
|
||||||
|
new() { TaskId = trash.Id, OccurrenceDate = today.AddDays(-1), CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddDays(-1) },
|
||||||
|
new() { TaskId = trash.Id, OccurrenceDate = today.AddDays(-3), CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddDays(-3) },
|
||||||
|
new() { TaskId = trash.Id, OccurrenceDate = today.AddDays(-5), CompletedByMemberId = m.Eve.Id, CompletedAt = now.AddDays(-5) },
|
||||||
|
// Dishwasher rotates between adults.
|
||||||
|
new() { TaskId = dishwasher.Id, OccurrenceDate = today, CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddHours(-3) },
|
||||||
|
new() { TaskId = dishwasher.Id, OccurrenceDate = today.AddDays(-1), CompletedByMemberId = m.Eve.Id, CompletedAt = now.AddDays(-1).AddHours(-2) },
|
||||||
|
new() { TaskId = dishwasher.Id, OccurrenceDate = today.AddDays(-2), CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddDays(-2) },
|
||||||
|
new() { TaskId = dishwasher.Id, OccurrenceDate = today.AddDays(-4), CompletedByMemberId = m.Eve.Id, CompletedAt = now.AddDays(-4) },
|
||||||
|
// Plants — Eve watered last Saturday.
|
||||||
|
new() { TaskId = plants.Id, OccurrenceDate = LastWeekday(today, DayOfWeek.Saturday).AddDays(-7), CompletedByMemberId = m.Eve.Id, CompletedAt = now.AddDays(-8) },
|
||||||
|
// Bathroom — Dan two Sundays ago.
|
||||||
|
new() { TaskId = bathroom.Id, OccurrenceDate = LastWeekday(today, DayOfWeek.Sunday).AddDays(-7), CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddDays(-10) },
|
||||||
|
// Rent — Dan paid this month.
|
||||||
|
new() { TaskId = rent.Id, OccurrenceDate = new DateOnly(today.Year, today.Month, 1), CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddDays(-today.Day + 1) },
|
||||||
|
// Kid helping out.
|
||||||
|
new() { TaskId = trash.Id, OccurrenceDate = today.AddDays(-7), CompletedByMemberId = m.Alice.Id, CompletedAt = now.AddDays(-7) },
|
||||||
|
new() { TaskId = trash.Id, OccurrenceDate = today.AddDays(-10), CompletedByMemberId = m.Bob.Id, CompletedAt = now.AddDays(-10) },
|
||||||
|
};
|
||||||
|
db.TaskCompletions.AddRange(completions);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly LastWeekday(DateOnly anchor, DayOfWeek target)
|
||||||
|
{
|
||||||
|
var diff = ((int)anchor.DayOfWeek - (int)target + 7) % 7;
|
||||||
|
return diff == 0 ? anchor : anchor.AddDays(-diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedWallAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
MarkdownRenderer markdown,
|
||||||
|
Family family,
|
||||||
|
DemoMembers m,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var memberCandidates = new List<MentionCandidate>
|
||||||
|
{
|
||||||
|
new(m.Dan.Id, m.Dan.DisplayName),
|
||||||
|
new(m.Eve.Id, m.Eve.DisplayName),
|
||||||
|
new(m.Alice.Id, m.Alice.DisplayName),
|
||||||
|
new(m.Bob.Id, m.Bob.DisplayName),
|
||||||
|
};
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var post1Source = "Grandma's coming over for dinner on Saturday — any dietary requests?";
|
||||||
|
var post1 = await CreateWallMessageAsync(
|
||||||
|
db, markdown, family, m.Dan, post1Source, isPinned: false,
|
||||||
|
createdAt: now.AddDays(-3), memberCandidates, cancellationToken);
|
||||||
|
await AddCommentAsync(db, markdown, post1, m.Eve,
|
||||||
|
"Maybe skip the cilantro this time, Alice doesn't love it 🌿",
|
||||||
|
now.AddDays(-3).AddHours(2), memberCandidates, cancellationToken);
|
||||||
|
await AddCommentAsync(db, markdown, post1, m.Alice,
|
||||||
|
"And lots of dessert please 🍰",
|
||||||
|
now.AddDays(-2).AddHours(-4), memberCandidates, cancellationToken);
|
||||||
|
AddReactions(db, post1, new[]
|
||||||
|
{
|
||||||
|
(m.Eve.Id, "❤️"),
|
||||||
|
(m.Alice.Id, "🎉"),
|
||||||
|
(m.Bob.Id, "🎉"),
|
||||||
|
}, now);
|
||||||
|
|
||||||
|
var post2Source = """
|
||||||
|
**🛒 Shopping list this week**
|
||||||
|
|
||||||
|
- Milk, eggs, sourdough
|
||||||
|
- Bananas, apples, lentils
|
||||||
|
- Olive oil, tomato sauce
|
||||||
|
- Toilet paper (we're almost out)
|
||||||
|
""";
|
||||||
|
var post2 = await CreateWallMessageAsync(
|
||||||
|
db, markdown, family, m.Eve, post2Source, isPinned: true,
|
||||||
|
createdAt: now.AddDays(-2), memberCandidates, cancellationToken);
|
||||||
|
AddReactions(db, post2, new[]
|
||||||
|
{
|
||||||
|
(m.Dan.Id, "👍"),
|
||||||
|
}, now);
|
||||||
|
|
||||||
|
var post3Source = "Alice's ballet recital is on the **30th**! 🩰 Mark your calendars.";
|
||||||
|
var post3 = await CreateWallMessageAsync(
|
||||||
|
db, markdown, family, m.Eve, post3Source, isPinned: false,
|
||||||
|
createdAt: now.AddDays(-1), memberCandidates, cancellationToken);
|
||||||
|
AddReactions(db, post3, new[]
|
||||||
|
{
|
||||||
|
(m.Dan.Id, "🎉"),
|
||||||
|
(m.Alice.Id, "❤️"),
|
||||||
|
}, now);
|
||||||
|
|
||||||
|
var post4Source = "Anybody up for bouldering tomorrow afternoon? Could rope Bob into trying 🧗";
|
||||||
|
var post4 = await CreateWallMessageAsync(
|
||||||
|
db, markdown, family, m.Dan, post4Source, isPinned: false,
|
||||||
|
createdAt: now.AddHours(-5), memberCandidates, cancellationToken);
|
||||||
|
await AddCommentAsync(db, markdown, post4, m.Eve,
|
||||||
|
"I'm in 💪",
|
||||||
|
now.AddHours(-2), memberCandidates, cancellationToken);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<WallMessage> CreateWallMessageAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
MarkdownRenderer markdown,
|
||||||
|
Family family,
|
||||||
|
FamilyMember author,
|
||||||
|
string source,
|
||||||
|
bool isPinned,
|
||||||
|
DateTimeOffset createdAt,
|
||||||
|
IReadOnlyList<MentionCandidate> candidates,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rendered = markdown.RenderWithMentions(source, candidates);
|
||||||
|
var message = new WallMessage
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
AuthorMemberId = author.Id,
|
||||||
|
Text = rendered.Markdown,
|
||||||
|
TextHtml = rendered.Html,
|
||||||
|
IsPinned = isPinned,
|
||||||
|
PinnedAt = isPinned ? createdAt : null,
|
||||||
|
};
|
||||||
|
db.WallMessages.Add(message);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// The DbContext audit-stamping overrides CreatedAt to "now" on insert,
|
||||||
|
// which would collapse every demo post into the same instant and ruin
|
||||||
|
// the chronological feed. Back-date in a second roundtrip that skips
|
||||||
|
// the SaveChanges pipeline so the screenshots show a credible spread.
|
||||||
|
await db.WallMessages
|
||||||
|
.Where(m => m.Id == message.Id)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(m => m.CreatedAt, createdAt)
|
||||||
|
.SetProperty(m => m.UpdatedAt, (DateTimeOffset?)null),
|
||||||
|
cancellationToken);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddCommentAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
MarkdownRenderer markdown,
|
||||||
|
WallMessage message,
|
||||||
|
FamilyMember author,
|
||||||
|
string source,
|
||||||
|
DateTimeOffset createdAt,
|
||||||
|
IReadOnlyList<MentionCandidate> candidates,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rendered = markdown.RenderWithMentions(source, candidates);
|
||||||
|
var comment = new WallComment
|
||||||
|
{
|
||||||
|
MessageId = message.Id,
|
||||||
|
AuthorMemberId = author.Id,
|
||||||
|
Text = rendered.Markdown,
|
||||||
|
TextHtml = rendered.Html,
|
||||||
|
};
|
||||||
|
db.WallComments.Add(comment);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Same backdating dance as wall messages — comments are ordered by
|
||||||
|
// CreatedAt under their parent message, and we want each to land
|
||||||
|
// visibly after the post it replies to.
|
||||||
|
await db.WallComments
|
||||||
|
.Where(c => c.Id == comment.Id)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(c => c.CreatedAt, createdAt)
|
||||||
|
.SetProperty(c => c.UpdatedAt, (DateTimeOffset?)null),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddReactions(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
WallMessage message,
|
||||||
|
IEnumerable<(Guid MemberId, string Emoji)> reactions,
|
||||||
|
DateTimeOffset at)
|
||||||
|
{
|
||||||
|
foreach (var (memberId, emoji) in reactions)
|
||||||
|
{
|
||||||
|
db.WallReactions.Add(new WallReaction
|
||||||
|
{
|
||||||
|
MessageId = message.Id,
|
||||||
|
MemberId = memberId,
|
||||||
|
Emoji = emoji,
|
||||||
|
ReactedAt = at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedMealsAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
Family family,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Anchor the meal plan to the Monday of the current week so the
|
||||||
|
// screenshots always show a full Mon-Sun grid no matter the weekday.
|
||||||
|
var monday = LastWeekday(today, DayOfWeek.Monday);
|
||||||
|
var slots = new[]
|
||||||
|
{
|
||||||
|
(monday.AddDays(0), MealSlot.Lunch, "Tomato salad", "Pasta carbonara"),
|
||||||
|
(monday.AddDays(0), MealSlot.Dinner, "Vegetable soup", "Grilled cheese"),
|
||||||
|
(monday.AddDays(1), MealSlot.Lunch, (string?)null, "Lentil stew"),
|
||||||
|
(monday.AddDays(1), MealSlot.Dinner, (string?)null, "Sushi takeout 🍣"),
|
||||||
|
(monday.AddDays(2), MealSlot.Lunch, "Caesar salad", "Roast chicken & potatoes"),
|
||||||
|
(monday.AddDays(2), MealSlot.Dinner, (string?)null, "Veggie omelette"),
|
||||||
|
(monday.AddDays(3), MealSlot.Lunch, (string?)null, "Beef tacos"),
|
||||||
|
(monday.AddDays(3), MealSlot.Dinner, "Greek yoghurt", "Buddha bowl"),
|
||||||
|
(monday.AddDays(4), MealSlot.Lunch, "Bruschetta", "Margherita pizza 🍕"),
|
||||||
|
(monday.AddDays(4), MealSlot.Dinner, (string?)null, "Leftovers"),
|
||||||
|
(monday.AddDays(5), MealSlot.Lunch, "Gazpacho", "Grandma's paella"),
|
||||||
|
(monday.AddDays(5), MealSlot.Dinner, (string?)null, "Cheese & charcuterie"),
|
||||||
|
(monday.AddDays(6), MealSlot.Lunch, (string?)null, "Roast lamb"),
|
||||||
|
(monday.AddDays(6), MealSlot.Dinner, (string?)null, "Tomato & basil soup"),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (date, slot, first, second) in slots)
|
||||||
|
{
|
||||||
|
db.MealPlanSlots.Add(new MealPlanSlot
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Date = date,
|
||||||
|
Slot = slot,
|
||||||
|
FirstCourse = first,
|
||||||
|
SecondCourse = second,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedSchoolAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
Family family,
|
||||||
|
DemoMembers m,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
db.SchoolProfiles.AddRange(
|
||||||
|
new SchoolProfile
|
||||||
|
{
|
||||||
|
FamilyMemberId = m.Alice.Id,
|
||||||
|
SchoolName = "Lincoln Elementary",
|
||||||
|
Grade = "3rd grade",
|
||||||
|
Tutor = "Ms. Johnson",
|
||||||
|
TransportMode = TransportMode.Walk,
|
||||||
|
MorningTime = new TimeOnly(8, 30),
|
||||||
|
AfternoonTime = new TimeOnly(16, 0),
|
||||||
|
},
|
||||||
|
new SchoolProfile
|
||||||
|
{
|
||||||
|
FamilyMemberId = m.Bob.Id,
|
||||||
|
SchoolName = "Lincoln Elementary",
|
||||||
|
Grade = "Kindergarten",
|
||||||
|
Tutor = "Mr. Lopez",
|
||||||
|
TransportMode = TransportMode.Walk,
|
||||||
|
MorningTime = new TimeOnly(8, 30),
|
||||||
|
AfternoonTime = new TimeOnly(13, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Weekly drop-off / pick-up routine, Mon-Fri. Dan walks them in, Eve
|
||||||
|
// picks them up — credible distribution that also shows both adults.
|
||||||
|
foreach (var dow in new[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday })
|
||||||
|
{
|
||||||
|
db.SchoolDaySchedules.Add(new SchoolDaySchedule
|
||||||
|
{
|
||||||
|
FamilyMemberId = m.Alice.Id,
|
||||||
|
DayOfWeek = dow,
|
||||||
|
DropoffMemberId = m.Dan.Id,
|
||||||
|
PickupMemberId = m.Eve.Id,
|
||||||
|
});
|
||||||
|
db.SchoolDaySchedules.Add(new SchoolDaySchedule
|
||||||
|
{
|
||||||
|
FamilyMemberId = m.Bob.Id,
|
||||||
|
DayOfWeek = dow,
|
||||||
|
DropoffMemberId = m.Dan.Id,
|
||||||
|
PickupMemberId = m.Eve.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One extracurricular per kid so the school overview has something
|
||||||
|
// visible without overwhelming the screenshot.
|
||||||
|
db.Extracurriculars.AddRange(
|
||||||
|
new Extracurricular
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
FamilyMemberId = m.Alice.Id,
|
||||||
|
Name = "Ballet",
|
||||||
|
Location = "Lincoln Dance Studio",
|
||||||
|
WeeklyDays = DayOfWeekMask.Tuesday | DayOfWeekMask.Thursday,
|
||||||
|
StartTime = new TimeOnly(17, 0),
|
||||||
|
EndTime = new TimeOnly(18, 0),
|
||||||
|
StartDate = today.AddMonths(-2),
|
||||||
|
DefaultDropoffMemberId = m.Eve.Id,
|
||||||
|
DefaultPickupMemberId = m.Eve.Id,
|
||||||
|
},
|
||||||
|
new Extracurricular
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
FamilyMemberId = m.Bob.Id,
|
||||||
|
Name = "Swimming",
|
||||||
|
Location = "Community Pool",
|
||||||
|
WeeklyDays = DayOfWeekMask.Wednesday,
|
||||||
|
StartTime = new TimeOnly(17, 30),
|
||||||
|
EndTime = new TimeOnly(18, 30),
|
||||||
|
StartDate = today.AddMonths(-2),
|
||||||
|
DefaultDropoffMemberId = m.Dan.Id,
|
||||||
|
DefaultPickupMemberId = m.Dan.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// One holiday in the near future to populate the "next break" UI.
|
||||||
|
db.SchoolHolidays.Add(new SchoolHoliday
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
Label = "Spring break",
|
||||||
|
StartDate = today.AddDays(21),
|
||||||
|
EndDate = today.AddDays(28),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedAgendaAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
Family family,
|
||||||
|
DemoMembers m,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Weekly "who's where" patterns so the dashboard agenda widget has
|
||||||
|
// signal. Kept minimal: working hours for the adults plus one evening
|
||||||
|
// activity each.
|
||||||
|
var workdays = new[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday };
|
||||||
|
foreach (var dow in workdays)
|
||||||
|
{
|
||||||
|
db.MemberAgendaPatterns.Add(new MemberAgendaPattern
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
FamilyMemberId = m.Dan.Id,
|
||||||
|
DayOfWeek = dow,
|
||||||
|
Label = "Office",
|
||||||
|
Location = "Downtown",
|
||||||
|
StartTime = new TimeOnly(9, 0),
|
||||||
|
EndTime = new TimeOnly(18, 0),
|
||||||
|
TransportMode = AgendaTransportMode.Bus,
|
||||||
|
IsAway = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
foreach (var dow in new[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday })
|
||||||
|
{
|
||||||
|
db.MemberAgendaPatterns.Add(new MemberAgendaPattern
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
FamilyMemberId = m.Eve.Id,
|
||||||
|
DayOfWeek = dow,
|
||||||
|
Label = "Studio",
|
||||||
|
Location = "Old town",
|
||||||
|
StartTime = new TimeOnly(10, 0),
|
||||||
|
EndTime = new TimeOnly(19, 0),
|
||||||
|
TransportMode = AgendaTransportMode.Other,
|
||||||
|
IsAway = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db.MemberAgendaPatterns.Add(new MemberAgendaPattern
|
||||||
|
{
|
||||||
|
FamilyId = family.Id,
|
||||||
|
FamilyMemberId = m.Eve.Id,
|
||||||
|
DayOfWeek = DayOfWeek.Wednesday,
|
||||||
|
Label = "Pilates",
|
||||||
|
Location = "Wellness Center",
|
||||||
|
StartTime = new TimeOnly(19, 30),
|
||||||
|
EndTime = new TimeOnly(20, 30),
|
||||||
|
TransportMode = AgendaTransportMode.Walk,
|
||||||
|
IsAway = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bundle of seeded members so the rest of the seeder can refer to
|
||||||
|
/// them by role without juggling four parameters everywhere.</summary>
|
||||||
|
private sealed record DemoMembers(FamilyMember Dan, FamilyMember Eve, FamilyMember Alice, FamilyMember Bob);
|
||||||
|
}
|
||||||
67
src/FamilyNido.Api/Bootstrap/DemoSeedOptions.cs
Normal file
67
src/FamilyNido.Api/Bootstrap/DemoSeedOptions.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
namespace FamilyNido.Api.Bootstrap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly-typed binding for the <c>Seed:Demo</c> configuration section.
|
||||||
|
/// Drives <see cref="DemoDataSeeder"/>, which only registers in the
|
||||||
|
/// <c>Development</c> environment and no-ops unless <see cref="Enabled"/> is
|
||||||
|
/// true. Used to drop a curated, screenshot-friendly scenario into an empty
|
||||||
|
/// database so the README assets can be captured without touching real data.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DemoSeedOptions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section name.</summary>
|
||||||
|
public const string SectionName = "Seed:Demo";
|
||||||
|
|
||||||
|
/// <summary>Master switch. The seeder no-ops unless this is true.</summary>
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Display name of the demo family. Idempotency key — re-running with
|
||||||
|
/// the same name is a no-op once the family exists.</summary>
|
||||||
|
public string FamilyName { get; init; } = "Smith Family";
|
||||||
|
|
||||||
|
/// <summary>IANA time zone for the demo family. The seeder anchors all dates
|
||||||
|
/// (tasks, meals, completions) to "today" in this zone so screenshots taken
|
||||||
|
/// in different time zones still look coherent.</summary>
|
||||||
|
public string TimeZone { get; init; } = "Europe/Madrid";
|
||||||
|
|
||||||
|
/// <summary>BCP-47 locale used for date/number formatting in the UI. The
|
||||||
|
/// per-user <c>PreferredLanguage</c> drives the actual UI language; pick
|
||||||
|
/// <c>en-US</c> here if your screenshots target an English README.</summary>
|
||||||
|
public string Locale { get; init; } = "en-US";
|
||||||
|
|
||||||
|
/// <summary>Preferred language each demo adult ends up with. Same hint as
|
||||||
|
/// <see cref="Locale"/> — overrides at the user level.</summary>
|
||||||
|
public string PreferredLanguage { get; init; } = "en-US";
|
||||||
|
|
||||||
|
/// <summary>Human-readable label rendered under the weather widget.</summary>
|
||||||
|
public string LocationLabel { get; init; } = "Bilbao";
|
||||||
|
|
||||||
|
/// <summary>Geographic latitude in decimal degrees. Drives the weather widget.</summary>
|
||||||
|
public double Latitude { get; init; } = 43.2630;
|
||||||
|
|
||||||
|
/// <summary>Geographic longitude in decimal degrees.</summary>
|
||||||
|
public double Longitude { get; init; } = -2.9350;
|
||||||
|
|
||||||
|
/// <summary>Email used for the primary adult (admin). Required to be able to
|
||||||
|
/// log in and capture screenshots; the password lives in
|
||||||
|
/// <see cref="AdminPassword"/>.</summary>
|
||||||
|
public string AdminEmail { get; init; } = "dan@familynido.demo";
|
||||||
|
|
||||||
|
/// <summary>Display name of the primary adult.</summary>
|
||||||
|
public string AdminDisplayName { get; init; } = "Dan";
|
||||||
|
|
||||||
|
/// <summary>Local-credential password for the admin. Required when
|
||||||
|
/// <see cref="Enabled"/> is true.</summary>
|
||||||
|
public string AdminPassword { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Email of the second adult.</summary>
|
||||||
|
public string PartnerEmail { get; init; } = "eve@familynido.demo";
|
||||||
|
|
||||||
|
/// <summary>Display name of the second adult.</summary>
|
||||||
|
public string PartnerDisplayName { get; init; } = "Eve";
|
||||||
|
|
||||||
|
/// <summary>Local-credential password for the second adult. Optional — if
|
||||||
|
/// empty, the partner is created without local credentials (you can still
|
||||||
|
/// browse as the admin).</summary>
|
||||||
|
public string PartnerPassword { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
45
src/FamilyNido.Api/Bootstrap/E2ESeedOptions.cs
Normal file
45
src/FamilyNido.Api/Bootstrap/E2ESeedOptions.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
namespace FamilyNido.Api.Bootstrap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly-typed binding for the <c>Seed:E2E</c> configuration section.
|
||||||
|
/// Only honored when <see cref="IHostEnvironment.EnvironmentName"/> is
|
||||||
|
/// <c>Testing</c>; production never reads this section.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class E2ESeedOptions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section name.</summary>
|
||||||
|
public const string SectionName = "Seed:E2E";
|
||||||
|
|
||||||
|
/// <summary>Master switch. The seeder no-ops unless this is true.</summary>
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Name of the test family. Used as the idempotency key.</summary>
|
||||||
|
public string FamilyName { get; init; } = "FamilyNido E2E";
|
||||||
|
|
||||||
|
/// <summary>IANA time zone for the seeded family.</summary>
|
||||||
|
public string TimeZone { get; init; } = "Europe/Madrid";
|
||||||
|
|
||||||
|
/// <summary>Email of admin tester A. Stable across runs.</summary>
|
||||||
|
public string UserAEmail { get; init; } = "e2e-a@FamilyNido.test";
|
||||||
|
|
||||||
|
/// <summary>Local-credential password for tester A. Required when <see cref="Enabled"/> is true.</summary>
|
||||||
|
public string UserAPassword { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Display name for tester A.</summary>
|
||||||
|
public string UserADisplayName { get; init; } = "Tester A";
|
||||||
|
|
||||||
|
/// <summary>Hex color for tester A's avatar.</summary>
|
||||||
|
public string UserAColorHex { get; init; } = "#3B82F6";
|
||||||
|
|
||||||
|
/// <summary>Email of adult tester B.</summary>
|
||||||
|
public string UserBEmail { get; init; } = "e2e-b@FamilyNido.test";
|
||||||
|
|
||||||
|
/// <summary>Local-credential password for tester B. Required when <see cref="Enabled"/> is true.</summary>
|
||||||
|
public string UserBPassword { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Display name for tester B.</summary>
|
||||||
|
public string UserBDisplayName { get; init; } = "Tester B";
|
||||||
|
|
||||||
|
/// <summary>Hex color for tester B's avatar.</summary>
|
||||||
|
public string UserBColorHex { get; init; } = "#EF4444";
|
||||||
|
}
|
||||||
149
src/FamilyNido.Api/Bootstrap/E2ETestDataSeeder.cs
Normal file
149
src/FamilyNido.Api/Bootstrap/E2ETestDataSeeder.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using FamilyNido.Domain.Identity;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Bootstrap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hosted service that, on startup, ensures a deterministic family + two
|
||||||
|
/// adult users (tester A as admin, tester B as adult) exist with local
|
||||||
|
/// credentials. Used solely by the Playwright E2E suite both locally and
|
||||||
|
/// in CI; production registration is gated on
|
||||||
|
/// <c>Environment == "Testing"</c> in <c>Program.cs</c>, and the seeder
|
||||||
|
/// itself bails unless <see cref="E2ESeedOptions.Enabled"/> is true.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Idempotent. Safe to run on every startup: existing rows are reused, so
|
||||||
|
/// repeated runs do not duplicate the family/users and do not rotate
|
||||||
|
/// already-set passwords.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class E2ETestDataSeeder : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly E2ESeedOptions _options;
|
||||||
|
private readonly ILogger<E2ETestDataSeeder> _logger;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public E2ETestDataSeeder(
|
||||||
|
IServiceProvider services,
|
||||||
|
IOptions<E2ESeedOptions> options,
|
||||||
|
ILogger<E2ETestDataSeeder> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_options.UserAPassword) || string.IsNullOrEmpty(_options.UserBPassword))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("E2E seeder enabled but Seed:E2E:UserAPassword or UserBPassword is empty — skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var scope = _services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var hasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<User>>();
|
||||||
|
|
||||||
|
var family = await db.Families
|
||||||
|
.FirstOrDefaultAsync(f => f.Name == _options.FamilyName, cancellationToken);
|
||||||
|
if (family is null)
|
||||||
|
{
|
||||||
|
family = new Family
|
||||||
|
{
|
||||||
|
Name = _options.FamilyName,
|
||||||
|
TimeZone = _options.TimeZone,
|
||||||
|
};
|
||||||
|
db.Families.Add(family);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"E2E seeder: created family {FamilyName} ({FamilyId}).",
|
||||||
|
family.Name, family.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureUserAsync(
|
||||||
|
db, hasher, family.Id,
|
||||||
|
_options.UserAEmail, _options.UserADisplayName,
|
||||||
|
_options.UserAPassword, _options.UserAColorHex,
|
||||||
|
FamilyRole.Admin, cancellationToken);
|
||||||
|
|
||||||
|
await EnsureUserAsync(
|
||||||
|
db, hasher, family.Id,
|
||||||
|
_options.UserBEmail, _options.UserBDisplayName,
|
||||||
|
_options.UserBPassword, _options.UserBColorHex,
|
||||||
|
FamilyRole.Adult, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureUserAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
IPasswordHasher<User> hasher,
|
||||||
|
Guid familyId,
|
||||||
|
string email,
|
||||||
|
string displayName,
|
||||||
|
string password,
|
||||||
|
string colorHex,
|
||||||
|
FamilyRole role,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var emailLower = email.ToLowerInvariant();
|
||||||
|
var user = await db.Users
|
||||||
|
.Include(u => u.Credentials)
|
||||||
|
.Include(u => u.FamilyMember)
|
||||||
|
.FirstOrDefaultAsync(u => u.Email.ToLower() == emailLower, cancellationToken);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
user = new User
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
DisplayName = displayName,
|
||||||
|
Role = role,
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var localCred = user.Credentials.FirstOrDefault(c => c.Provider == IdentityProvider.Local);
|
||||||
|
if (localCred is null)
|
||||||
|
{
|
||||||
|
db.UserCredentials.Add(new UserCredential
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
Provider = IdentityProvider.Local,
|
||||||
|
PasswordHash = hasher.HashPassword(user, password),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.FamilyMember is null)
|
||||||
|
{
|
||||||
|
db.FamilyMembers.Add(new FamilyMember
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
DisplayName = displayName,
|
||||||
|
MemberType = MemberType.Adult,
|
||||||
|
ColorHex = colorHex,
|
||||||
|
UserId = user.Id,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("E2E seeder: ensured user {Email} ({Role}).", email, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/FamilyNido.Api/FamilyNido.Api.csproj
Normal file
36
src/FamilyNido.Api/FamilyNido.Api.csproj
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
|
||||||
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
|
<PackageReference Include="Google.Apis.Auth" Version="1.69.0" />
|
||||||
|
<PackageReference Include="Google.Apis.Calendar.v3" Version="1.69.0.3746" />
|
||||||
|
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||||
|
<PackageReference Include="Markdig" Version="0.41.3" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
|
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||||
|
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\FamilyNido.Domain\FamilyNido.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\FamilyNido.Persistence\FamilyNido.Persistence.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
181
src/FamilyNido.Api/Features/Auth/AuthEndpoints.cs
Normal file
181
src/FamilyNido.Api/Features/Auth/AuthEndpoints.cs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using FamilyNido.Api.Shared.Http;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>Endpoints for the authentication dance (OIDC, local login, credentials, me).</summary>
|
||||||
|
public static class AuthEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Rate-limit policy name applied to <c>POST /api/auth/local/login</c>.</summary>
|
||||||
|
public const string LocalLoginRateLimitPolicy = "local-login";
|
||||||
|
|
||||||
|
/// <summary>Registers the /api/auth endpoints on the given route group.</summary>
|
||||||
|
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/auth").WithTags("Auth");
|
||||||
|
|
||||||
|
// Anonymous discovery endpoint: lets the login screen know whether the
|
||||||
|
// OIDC button should be shown. Treats an empty Authority as "OIDC
|
||||||
|
// disabled" — which is the default for the dev stack, where the family
|
||||||
|
// signs in with local credentials.
|
||||||
|
group.MapGet("/providers", (IOptions<OidcOptions> oidc) =>
|
||||||
|
{
|
||||||
|
var enabled = !string.IsNullOrWhiteSpace(oidc.Value.Authority);
|
||||||
|
return Results.Ok(new { oidcEnabled = enabled });
|
||||||
|
}).AllowAnonymous();
|
||||||
|
|
||||||
|
// OIDC challenge & cookie sign-out. Returns 404 when no Authority is
|
||||||
|
// configured so that a misclick on a cached login button doesn't
|
||||||
|
// surface a 500 from the auth middleware.
|
||||||
|
group.MapGet("/login", (string? returnUrl, IOptions<OidcOptions> oidc) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(oidc.Value.Authority))
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = "oidc_disabled" });
|
||||||
|
}
|
||||||
|
// returnUrl is reflected into the OIDC AuthenticationProperties and
|
||||||
|
// becomes the post-login redirect target. Restrict to local paths
|
||||||
|
// ("/something", not "//evil.com" or "https://evil.com") so an
|
||||||
|
// attacker can't craft `/api/auth/login?returnUrl=…` that ends up
|
||||||
|
// sending the freshly-signed-in user to a phishing page.
|
||||||
|
returnUrl = SanitizeLocalReturnUrl(returnUrl);
|
||||||
|
return Results.Challenge(
|
||||||
|
new AuthenticationProperties { RedirectUri = returnUrl },
|
||||||
|
[OpenIdConnectDefaults.AuthenticationScheme]);
|
||||||
|
}).AllowAnonymous();
|
||||||
|
|
||||||
|
// Drop the local cookie unconditionally. We *don't* drive a federated
|
||||||
|
// OIDC sign-out from here: not every session originates from PocketID
|
||||||
|
// (local-credentials users have no id_token) and the redirect flow
|
||||||
|
// doesn't compose well with the SPA's POST-from-fetch logout. If we
|
||||||
|
// ever need full SLO, a separate /logout/federated endpoint can opt
|
||||||
|
// into SignOutAsync(OpenIdConnectDefaults...).
|
||||||
|
group.MapPost("/logout", async (HttpContext http) =>
|
||||||
|
{
|
||||||
|
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return Results.NoContent();
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/me", GetMeAsync).RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
|
||||||
|
group.MapPut("/me/preferred-language", UpdatePreferredLanguageAsync)
|
||||||
|
.RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
|
||||||
|
// Local credentials.
|
||||||
|
group.MapPost("/local/login", LocalLoginAsync)
|
||||||
|
.AllowAnonymous()
|
||||||
|
.RequireRateLimiting(LocalLoginRateLimitPolicy);
|
||||||
|
|
||||||
|
group.MapPost("/local/set-password", SetLocalPasswordAsync)
|
||||||
|
.RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
|
||||||
|
group.MapGet("/credentials", ListCredentialsAsync)
|
||||||
|
.RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
|
||||||
|
group.MapDelete("/credentials/{id:guid}", RemoveCredentialAsync)
|
||||||
|
.RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A return URL is "safe" when it is a same-origin absolute path. Reject
|
||||||
|
// schemes, network-relative URLs ("//host"), and anything missing the
|
||||||
|
// leading slash; fall back to "/" so the redirect always lands inside
|
||||||
|
// the SPA.
|
||||||
|
private static string SanitizeLocalReturnUrl(string? candidate)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(candidate))
|
||||||
|
{
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
if (!candidate.StartsWith('/') || candidate.StartsWith("//", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetMeAsync(IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new GetMe.Query(), ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> LocalLoginAsync(
|
||||||
|
LocalLogin.Command command,
|
||||||
|
IValidator<LocalLogin.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> SetLocalPasswordAsync(
|
||||||
|
SetLocalPassword.Command command,
|
||||||
|
IValidator<SetLocalPassword.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.NoContent()
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListCredentialsAsync(IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new ListMyCredentials.Query(), ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RemoveCredentialAsync(Guid id, IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new RemoveCredential.Command(id), ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.NoContent()
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdatePreferredLanguageAsync(
|
||||||
|
UpdateMyPreferredLanguage.Command command,
|
||||||
|
IValidator<UpdateMyPreferredLanguage.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/FamilyNido.Api/Features/Auth/AuthenticationExtensions.cs
Normal file
160
src/FamilyNido.Api/Features/Auth/AuthenticationExtensions.cs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using FamilyNido.Api.Features.Integrations;
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>Composition of authentication/authorization for the API.</summary>
|
||||||
|
public static class AuthenticationExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Wires the cookie session, the integration API-key handler, the FamilyNido
|
||||||
|
/// policy set, and — when an OIDC <see cref="OidcOptions.Authority"/> is
|
||||||
|
/// configured — the OpenID Connect challenge handler. The OIDC scheme is
|
||||||
|
/// registered conditionally so that a deployment using only local
|
||||||
|
/// credentials doesn't trip <c>OpenIdConnectOptions.Validate()</c> on every
|
||||||
|
/// request the moment the auth middleware materializes its handlers.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddFamilyNidoAuthentication(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IWebHostEnvironment environment)
|
||||||
|
{
|
||||||
|
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
|
||||||
|
|
||||||
|
// Peek at the bound options to decide whether the OIDC scheme is on.
|
||||||
|
// We can't resolve IOptions here (the provider isn't built yet), but
|
||||||
|
// we can bind a temporary instance from the same section.
|
||||||
|
var oidcOptions = new OidcOptions();
|
||||||
|
configuration.GetSection(OidcOptions.SectionName).Bind(oidcOptions);
|
||||||
|
var oidcEnabled = !string.IsNullOrWhiteSpace(oidcOptions.Authority);
|
||||||
|
|
||||||
|
var authBuilder = services
|
||||||
|
.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
// Only point the challenge/sign-out chain at OIDC when it's
|
||||||
|
// actually wired up. With OIDC off, an unauthenticated request
|
||||||
|
// to a [Authorize] endpoint stays on the cookie scheme, which
|
||||||
|
// emits a 401 (or hits OnRedirectToLogin for non-API paths).
|
||||||
|
if (oidcEnabled)
|
||||||
|
{
|
||||||
|
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultSignOutScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.AddCookie(options =>
|
||||||
|
{
|
||||||
|
options.Cookie.Name = "familynido.session";
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
// Force the Secure flag in production so the cookie is never
|
||||||
|
// sent over plain HTTP; SameAsRequest in dev/Testing keeps the
|
||||||
|
// local HTTP loop and the in-process WebApplicationFactory
|
||||||
|
// workflow working without TLS.
|
||||||
|
options.Cookie.SecurePolicy = environment.IsProduction()
|
||||||
|
? CookieSecurePolicy.Always
|
||||||
|
: CookieSecurePolicy.SameAsRequest;
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
options.Events.OnRedirectToLogin = ctx =>
|
||||||
|
{
|
||||||
|
if (ctx.Request.Path.StartsWithSegments("/api"))
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Response.Redirect(ctx.RedirectUri);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.AddScheme<IntegrationApiKeyAuthenticationOptions, IntegrationApiKeyAuthenticationHandler>(
|
||||||
|
IntegrationApiKeyDefaults.AuthenticationScheme,
|
||||||
|
_ => { });
|
||||||
|
|
||||||
|
if (oidcEnabled)
|
||||||
|
{
|
||||||
|
authBuilder.AddOpenIdConnect(oidc =>
|
||||||
|
{
|
||||||
|
oidc.ResponseType = OpenIdConnectResponseType.Code;
|
||||||
|
oidc.UsePkce = true;
|
||||||
|
// SaveTokens=false: the API never re-uses access/refresh
|
||||||
|
// tokens after the initial callback, so persisting them in
|
||||||
|
// the session cookie just bloats every request header.
|
||||||
|
// Keeping them out shrinks the cookie by 4–8 KB and avoids
|
||||||
|
// tripping reverse-proxy header limits on every callback.
|
||||||
|
oidc.SaveTokens = false;
|
||||||
|
oidc.GetClaimsFromUserInfoEndpoint = true;
|
||||||
|
oidc.MapInboundClaims = false;
|
||||||
|
oidc.TokenValidationParameters.NameClaimType = "name";
|
||||||
|
oidc.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
|
||||||
|
oidc.Events.OnTokenValidated = EnrichWithRoleClaimAsync;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply config values to OpenIdConnectOptions via IConfigureOptions so we
|
||||||
|
// don't have to build an intermediate provider.
|
||||||
|
services.AddOptions<OpenIdConnectOptions>(OpenIdConnectDefaults.AuthenticationScheme)
|
||||||
|
.Configure<IOptions<OidcOptions>>((oidc, options) =>
|
||||||
|
{
|
||||||
|
var config = options.Value;
|
||||||
|
oidc.Authority = config.Authority;
|
||||||
|
oidc.ClientId = config.ClientId;
|
||||||
|
oidc.ClientSecret = string.IsNullOrEmpty(config.ClientSecret) ? null : config.ClientSecret;
|
||||||
|
oidc.RequireHttpsMetadata = config.RequireHttpsMetadata;
|
||||||
|
oidc.CallbackPath = config.CallbackPath;
|
||||||
|
oidc.SignedOutCallbackPath = config.SignedOutCallbackPath;
|
||||||
|
oidc.Scope.Clear();
|
||||||
|
foreach (var scope in config.Scopes)
|
||||||
|
{
|
||||||
|
oidc.Scope.Add(scope);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddAuthorizationBuilder()
|
||||||
|
.AddFamilyNidoPolicies();
|
||||||
|
|
||||||
|
services.AddScoped<ICurrentUserContext, CurrentUserContext>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnrichWithRoleClaimAsync(TokenValidatedContext ctx)
|
||||||
|
{
|
||||||
|
var identity = (ClaimsIdentity?)ctx.Principal?.Identity;
|
||||||
|
var subject = ctx.Principal?.FindFirst("sub")?.Value;
|
||||||
|
|
||||||
|
if (identity is null || subject is null || ctx.Principal is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, subject));
|
||||||
|
|
||||||
|
// Use ResolveAsync(principal): at this point HttpContext.User is still
|
||||||
|
// anonymous, so GetAsync would skip the DB work and the role claim
|
||||||
|
// baked into the session cookie would always be Guest.
|
||||||
|
var userContext = ctx.HttpContext.RequestServices.GetRequiredService<ICurrentUserContext>();
|
||||||
|
var resolved = await userContext.ResolveAsync(ctx.Principal, ctx.HttpContext.RequestAborted);
|
||||||
|
|
||||||
|
var role = resolved?.User.Role ?? FamilyRole.Guest;
|
||||||
|
identity.AddClaim(new Claim(ClaimTypes.Role, role.ToString()));
|
||||||
|
|
||||||
|
// Stamp the cookie with the internal user id so subsequent requests can
|
||||||
|
// resolve by primary key instead of joining through credentials. Works
|
||||||
|
// for orphan users too: their cookie is valid even without a member,
|
||||||
|
// which lets the front route them to the "ask the admin" screen with
|
||||||
|
// full identity context.
|
||||||
|
if (resolved is not null)
|
||||||
|
{
|
||||||
|
identity.AddClaim(new Claim(CurrentUserContext.UserIdClaimType, resolved.User.Id.ToString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
261
src/FamilyNido.Api/Features/Auth/CurrentUserContext.cs
Normal file
261
src/FamilyNido.Api/Features/Auth/CurrentUserContext.cs
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
using System.Data;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using FamilyNido.Domain.Identity;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default implementation of <see cref="ICurrentUserContext"/>. Caches the
|
||||||
|
/// lookup per request (it is registered scoped) so repeated calls within a
|
||||||
|
/// single HTTP request do not re-hit the database.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CurrentUserContext : ICurrentUserContext
|
||||||
|
{
|
||||||
|
/// <summary>Custom claim type holding the internal <see cref="User"/> id (Guid string).</summary>
|
||||||
|
public const string UserIdClaimType = "userId";
|
||||||
|
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly FamilyOptions _familyOptions;
|
||||||
|
|
||||||
|
private CurrentUser? _cached;
|
||||||
|
private bool _resolved;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public CurrentUserContext(
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
ApplicationDbContext db,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IOptions<FamilyOptions> familyOptions)
|
||||||
|
{
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
_db = db;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
_familyOptions = familyOptions.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<User?> GetUserAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var principal = _httpContextAccessor.HttpContext?.User;
|
||||||
|
if (principal?.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ResolvePrincipalUserAsync(principal, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CurrentUser?> GetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_resolved)
|
||||||
|
{
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolved = true;
|
||||||
|
|
||||||
|
var principal = _httpContextAccessor.HttpContext?.User;
|
||||||
|
if (principal?.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return _cached = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await ResolvePrincipalUserAsync(principal, cancellationToken);
|
||||||
|
// Orphan users (no FamilyMember linked yet) get the same null treatment as
|
||||||
|
// unauthenticated callers from the handlers' perspective. The /api/auth/me
|
||||||
|
// slice translates this into a 403 auth.not_linked so the front can route
|
||||||
|
// them to the "ask the admin for an invitation" screen.
|
||||||
|
if (user?.FamilyMember?.Family is null)
|
||||||
|
{
|
||||||
|
return _cached = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _cached = new CurrentUser(user, user.FamilyMember, user.FamilyMember.Family);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ResolvedIdentity?> ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var subject = ExtractOidcSubject(principal);
|
||||||
|
if (string.IsNullOrWhiteSpace(subject))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var email = principal.FindFirst(ClaimTypes.Email)?.Value
|
||||||
|
?? principal.FindFirst("email")?.Value
|
||||||
|
?? "";
|
||||||
|
var displayName = principal.FindFirst("name")?.Value
|
||||||
|
?? principal.FindFirst(ClaimTypes.Name)?.Value
|
||||||
|
?? (email.Contains('@', StringComparison.Ordinal)
|
||||||
|
? email.Split('@', 2)[0]
|
||||||
|
: subject);
|
||||||
|
|
||||||
|
var credential = await _db.UserCredentials
|
||||||
|
.Include(c => c.User)
|
||||||
|
.ThenInclude(u => u!.FamilyMember)
|
||||||
|
.ThenInclude(m => m!.Family)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
c => c.Provider == IdentityProvider.Oidc && c.ProviderKey == subject,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (credential is { User: not null })
|
||||||
|
{
|
||||||
|
var existing = credential.User;
|
||||||
|
existing.LastLoginAt = _timeProvider.GetUtcNow();
|
||||||
|
credential.LastUsedAt = _timeProvider.GetUtcNow();
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new ResolvedIdentity(existing, existing.FamilyMember, existing.FamilyMember?.Family);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-ever login path. The "is the DB empty?" check followed by the
|
||||||
|
// family + admin insert needs to be atomic — without serialization,
|
||||||
|
// two concurrent OIDC callbacks could both see an empty Users table
|
||||||
|
// and each spawn a separate family marked admin. A Serializable
|
||||||
|
// transaction makes Postgres reject one of the two with a 40001
|
||||||
|
// serialization_failure; that request reads the now-non-empty state
|
||||||
|
// on retry and falls into the orphan branch as intended.
|
||||||
|
await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
|
||||||
|
var alreadyInitialized = await _db.Users.AnyAsync(cancellationToken);
|
||||||
|
if (!alreadyInitialized && _familyOptions.BootstrapFirstUserAsAdmin)
|
||||||
|
{
|
||||||
|
var identity = await BootstrapFirstAdminAsync(subject, email, displayName, cancellationToken);
|
||||||
|
await tx.CommitAsync(cancellationToken);
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New OIDC subject on an already-initialized instance. Persist an orphan
|
||||||
|
// User + its credential so an admin can list and invite them later, and
|
||||||
|
// so the cookie can carry a stable `userId` claim. The caller (GetMe)
|
||||||
|
// still translates "no FamilyMember" into 403 auth.not_linked.
|
||||||
|
var orphan = await CreateOrphanUserAsync(subject, email, displayName, cancellationToken);
|
||||||
|
await tx.CommitAsync(cancellationToken);
|
||||||
|
return orphan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ResolvedIdentity> BootstrapFirstAdminAsync(
|
||||||
|
string subject,
|
||||||
|
string email,
|
||||||
|
string displayName,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var newFamily = new Family
|
||||||
|
{
|
||||||
|
Name = _familyOptions.DefaultName,
|
||||||
|
TimeZone = _familyOptions.DefaultTimeZone,
|
||||||
|
Locale = _familyOptions.DefaultLocale,
|
||||||
|
};
|
||||||
|
|
||||||
|
var newUser = new User
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
DisplayName = displayName,
|
||||||
|
Role = FamilyRole.Admin,
|
||||||
|
LastLoginAt = _timeProvider.GetUtcNow(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var newCredential = new UserCredential
|
||||||
|
{
|
||||||
|
UserId = newUser.Id,
|
||||||
|
User = newUser,
|
||||||
|
Provider = IdentityProvider.Oidc,
|
||||||
|
ProviderKey = subject,
|
||||||
|
LastUsedAt = _timeProvider.GetUtcNow(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var newMember = new FamilyMember
|
||||||
|
{
|
||||||
|
FamilyId = newFamily.Id,
|
||||||
|
Family = newFamily,
|
||||||
|
DisplayName = displayName,
|
||||||
|
MemberType = MemberType.Adult,
|
||||||
|
ColorHex = "#C96442",
|
||||||
|
ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email,
|
||||||
|
User = newUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Families.Add(newFamily);
|
||||||
|
_db.Users.Add(newUser);
|
||||||
|
_db.UserCredentials.Add(newCredential);
|
||||||
|
_db.FamilyMembers.Add(newMember);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ResolvedIdentity(newUser, newMember, newFamily);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ResolvedIdentity> CreateOrphanUserAsync(
|
||||||
|
string subject,
|
||||||
|
string email,
|
||||||
|
string displayName,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var newUser = new User
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
DisplayName = displayName,
|
||||||
|
Role = FamilyRole.Guest,
|
||||||
|
LastLoginAt = _timeProvider.GetUtcNow(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var newCredential = new UserCredential
|
||||||
|
{
|
||||||
|
UserId = newUser.Id,
|
||||||
|
User = newUser,
|
||||||
|
Provider = IdentityProvider.Oidc,
|
||||||
|
ProviderKey = subject,
|
||||||
|
LastUsedAt = _timeProvider.GetUtcNow(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Users.Add(newUser);
|
||||||
|
_db.UserCredentials.Add(newCredential);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ResolvedIdentity(newUser, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the persisted <see cref="User"/> for an authenticated principal.
|
||||||
|
/// Prefers the fast <c>userId</c> claim baked into the cookie at login time;
|
||||||
|
/// falls back to the OIDC <c>sub</c> claim via <see cref="UserCredential"/>
|
||||||
|
/// for legacy sessions issued before this claim was added.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<User?> ResolvePrincipalUserAsync(ClaimsPrincipal principal, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userIdClaim = principal.FindFirst(UserIdClaimType)?.Value;
|
||||||
|
if (Guid.TryParse(userIdClaim, out var userId))
|
||||||
|
{
|
||||||
|
return await _db.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(u => u.FamilyMember)
|
||||||
|
.ThenInclude(m => m!.Family)
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var subject = ExtractOidcSubject(principal);
|
||||||
|
if (string.IsNullOrWhiteSpace(subject))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _db.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(u => u.FamilyMember)
|
||||||
|
.ThenInclude(m => m!.Family)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
u => u.Credentials.Any(c => c.Provider == IdentityProvider.Oidc && c.ProviderKey == subject),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractOidcSubject(ClaimsPrincipal principal)
|
||||||
|
=> principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||||
|
?? principal.FindFirst("sub")?.Value;
|
||||||
|
}
|
||||||
46
src/FamilyNido.Api/Features/Auth/GetMe.cs
Normal file
46
src/FamilyNido.Api/Features/Auth/GetMe.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>Slice for <c>GET /api/auth/me</c>: returns the profile of the caller.</summary>
|
||||||
|
public static class GetMe
|
||||||
|
{
|
||||||
|
/// <summary>Query carrying no input — the caller is resolved from the ambient context.</summary>
|
||||||
|
public sealed record Query : IRequest<Result<MeDto>>;
|
||||||
|
|
||||||
|
/// <summary>Builds the profile DTO from <see cref="ICurrentUserContext"/>.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Query, Result<MeDto>>
|
||||||
|
{
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ICurrentUserContext userContext) => _userContext = userContext;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<MeDto>> HandleAsync(Query request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden(
|
||||||
|
"auth.not_linked",
|
||||||
|
"Your account is not linked to a family member. Ask the family admin to link you.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MeDto(
|
||||||
|
UserId: current.User.Id,
|
||||||
|
Email: current.User.Email,
|
||||||
|
DisplayName: current.User.DisplayName,
|
||||||
|
Role: current.User.Role,
|
||||||
|
FamilyId: current.Family.Id,
|
||||||
|
FamilyName: current.Family.Name,
|
||||||
|
MemberId: current.Member.Id,
|
||||||
|
MemberDisplayName: current.Member.DisplayName,
|
||||||
|
ColorHex: current.Member.ColorHex,
|
||||||
|
PhotoPath: current.Member.PhotoPath,
|
||||||
|
PreferredLanguage: current.User.PreferredLanguage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/FamilyNido.Api/Features/Auth/HttpContextActorProvider.cs
Normal file
32
src/FamilyNido.Api/Features/Auth/HttpContextActorProvider.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="ICurrentActorProvider"/> implementation that reads the
|
||||||
|
/// authenticated subject from the active <see cref="HttpContext"/>. Falls back
|
||||||
|
/// to <c>"system"</c> when no request is in flight (background work, startup,
|
||||||
|
/// design-time). Registered by the API composition root.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HttpContextActorProvider : ICurrentActorProvider
|
||||||
|
{
|
||||||
|
private readonly IHttpContextAccessor _accessor;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public HttpContextActorProvider(IHttpContextAccessor accessor) => _accessor = accessor;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetActor()
|
||||||
|
{
|
||||||
|
var principal = _accessor.HttpContext?.User;
|
||||||
|
// Prefer the internal userId claim (Guid) so audit trails are stable
|
||||||
|
// across credential rotations (a user could swap OIDC for local at any
|
||||||
|
// point, but their User.Id never changes). Fall back to the OIDC sub
|
||||||
|
// for sessions issued before the userId claim was introduced, and to
|
||||||
|
// "system" outside HTTP requests.
|
||||||
|
return principal?.FindFirstValue(CurrentUserContext.UserIdClaimType)
|
||||||
|
?? principal?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? "system";
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/FamilyNido.Api/Features/Auth/ICurrentUserContext.cs
Normal file
61
src/FamilyNido.Api/Features/Auth/ICurrentUserContext.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using FamilyNido.Domain.Identity;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-request context resolving the authenticated caller to their persisted
|
||||||
|
/// <see cref="User"/>, <see cref="FamilyMember"/> and <see cref="Family"/>.
|
||||||
|
/// Handles first-login bootstrapping (RF-USR-005, RF-AUTH-003) and stamps
|
||||||
|
/// the cookie with the internal <c>userId</c> claim used for fast lookups.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICurrentUserContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Steady-state lookup against <c>HttpContext.User</c>. Never mutates the
|
||||||
|
/// database — use this from endpoints and hubs. Returns <c>null</c> when
|
||||||
|
/// the caller is unauthenticated, the user row is gone, or the user has
|
||||||
|
/// no <see cref="FamilyMember"/> linked yet (pre-onboarding).
|
||||||
|
/// </summary>
|
||||||
|
Task<CurrentUser?> GetAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves only the <see cref="User"/> row, even when no
|
||||||
|
/// <see cref="FamilyMember"/> is linked. Used by onboarding flows
|
||||||
|
/// (accept-invitation) where the caller is authenticated but still orphan.
|
||||||
|
/// Returns <c>null</c> when the caller is unauthenticated or the row is gone.
|
||||||
|
/// </summary>
|
||||||
|
Task<User?> GetUserAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provisioning variant used inside the OIDC <c>OnTokenValidated</c> event
|
||||||
|
/// where <c>HttpContext.User</c> is still anonymous. Bootstraps the first
|
||||||
|
/// admin + family on a fresh database, creates an orphan <see cref="User"/>
|
||||||
|
/// (plus its <see cref="UserCredential"/>) for new subjects on an
|
||||||
|
/// already-initialized instance, or refreshes <c>LastLoginAt</c> on
|
||||||
|
/// repeated logins.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The resolved identity when an OIDC subject was present (linked or
|
||||||
|
/// orphan); <c>null</c> when the principal carries no usable subject.
|
||||||
|
/// </returns>
|
||||||
|
Task<ResolvedIdentity?> ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolved per-request identity used by endpoints and SignalR hubs.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="User">Internal user row.</param>
|
||||||
|
/// <param name="Member">Linked family member.</param>
|
||||||
|
/// <param name="Family">Owning family.</param>
|
||||||
|
public sealed record CurrentUser(User User, FamilyMember Member, Family Family);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identity resolved during the OIDC callback. <see cref="Member"/> and
|
||||||
|
/// <see cref="Family"/> may be null while the user waits to be linked.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="User">Internal user row (always present once resolved).</param>
|
||||||
|
/// <param name="Member">Linked family member, or null when the user is orphan.</param>
|
||||||
|
/// <param name="Family">Owning family, or null when the user is orphan.</param>
|
||||||
|
public sealed record ResolvedIdentity(User User, FamilyMember? Member, Family? Family);
|
||||||
75
src/FamilyNido.Api/Features/Auth/ListMyCredentials.cs
Normal file
75
src/FamilyNido.Api/Features/Auth/ListMyCredentials.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Identity;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: the authenticated user inspects their own credentials. Used by the
|
||||||
|
/// "Mi cuenta" screen to show which login methods are active and to drive
|
||||||
|
/// the "establecer / cambiar / eliminar" buttons.
|
||||||
|
/// </summary>
|
||||||
|
public static class ListMyCredentials
|
||||||
|
{
|
||||||
|
/// <summary>Query — no inputs.</summary>
|
||||||
|
public sealed record Query : IRequest<Result<IReadOnlyList<CredentialDto>>>;
|
||||||
|
|
||||||
|
/// <summary>Handler.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Query, Result<IReadOnlyList<CredentialDto>>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<IReadOnlyList<CredentialDto>>> HandleAsync(Query request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var user = await _userContext.GetUserAsync(ct);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "Caller is not authenticated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = await _db.UserCredentials
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.UserId == user.Id)
|
||||||
|
.OrderBy(c => c.Provider)
|
||||||
|
.ThenByDescending(c => c.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
IReadOnlyList<CredentialDto> dto = [.. rows.Select(c => new CredentialDto(
|
||||||
|
Id: c.Id,
|
||||||
|
Provider: c.Provider,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
LastUsedAt: c.LastUsedAt,
|
||||||
|
ProviderKeyHint: c.Provider == IdentityProvider.Oidc ? Hint(c.ProviderKey) : null))];
|
||||||
|
|
||||||
|
return Result<IReadOnlyList<CredentialDto>>.Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Hint(string? key)
|
||||||
|
=> string.IsNullOrEmpty(key) ? null : key.Length <= 6 ? key : $"…{key[^6..]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Read-model returned by <c>GET /api/auth/credentials</c>.</summary>
|
||||||
|
/// <param name="Id">Credential id.</param>
|
||||||
|
/// <param name="Provider">Provider type.</param>
|
||||||
|
/// <param name="CreatedAt">UTC instant the credential was added.</param>
|
||||||
|
/// <param name="LastUsedAt">UTC instant of last successful login through this credential.</param>
|
||||||
|
/// <param name="ProviderKeyHint">Last 6 chars of the OIDC subject (for UX disambiguation). Null for Local.</param>
|
||||||
|
public sealed record CredentialDto(
|
||||||
|
Guid Id,
|
||||||
|
IdentityProvider Provider,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? LastUsedAt,
|
||||||
|
string? ProviderKeyHint);
|
||||||
138
src/FamilyNido.Api/Features/Auth/LocalLogin.cs
Normal file
138
src/FamilyNido.Api/Features/Auth/LocalLogin.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Identity;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: anonymous local-credentials login. Returns a generic 401 for both
|
||||||
|
/// "user does not exist" and "password mismatch" so the endpoint cannot be
|
||||||
|
/// used to enumerate accounts. The endpoint signs the cookie itself instead
|
||||||
|
/// of going through ASP.NET's challenge dance, so the cookie ends up
|
||||||
|
/// equivalent in shape to one minted by the OIDC callback (same claim set).
|
||||||
|
/// </summary>
|
||||||
|
public static class LocalLogin
|
||||||
|
{
|
||||||
|
/// <summary>Command — wire payload from the front.</summary>
|
||||||
|
/// <param name="Email">Account email (case-insensitive).</param>
|
||||||
|
/// <param name="Password">Plain-text password.</param>
|
||||||
|
public sealed record Command(string Email, string Password) : IRequest<Result<LocalLoginResponse>>;
|
||||||
|
|
||||||
|
/// <summary>Input validation. Mirrors what the front already checks; backend stays the source of truth.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator with all rules registered.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Email).NotEmpty().EmailAddress().MaximumLength(254);
|
||||||
|
RuleFor(x => x.Password).NotEmpty().MaximumLength(256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Handler.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<LocalLoginResponse>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly IPasswordHasher<User> _passwordHasher;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
IPasswordHasher<User> passwordHasher,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_passwordHasher = passwordHasher;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<LocalLoginResponse>> HandleAsync(Command request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var email = request.Email.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
// Pull the user with its local credential. EmailIndex is unique
|
||||||
|
// so this is a single index lookup. We materialize the row even
|
||||||
|
// for non-existing users to keep the timing similar across
|
||||||
|
// outcomes — minor mitigation against trivial timing oracles.
|
||||||
|
var user = await _db.Users
|
||||||
|
.Include(u => u.Credentials)
|
||||||
|
.FirstOrDefaultAsync(u => u.Email.ToLower() == email, ct);
|
||||||
|
|
||||||
|
var credential = user?.Credentials.FirstOrDefault(c => c.Provider == IdentityProvider.Local);
|
||||||
|
|
||||||
|
if (user is null || credential is null || string.IsNullOrEmpty(credential.PasswordHash))
|
||||||
|
{
|
||||||
|
// Hash the supplied password against a dummy hash so the
|
||||||
|
// failure path takes a similar amount of time as success.
|
||||||
|
_passwordHasher.VerifyHashedPassword(new User { Email = "x", DisplayName = "x" }, DummyHash, request.Password);
|
||||||
|
return ApplicationError.Forbidden("auth.invalid_credentials", "Invalid credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var verification = _passwordHasher.VerifyHashedPassword(user, credential.PasswordHash, request.Password);
|
||||||
|
if (verification == PasswordVerificationResult.Failed)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.invalid_credentials", "Invalid credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quietly upgrade the hash format if the framework recommends it
|
||||||
|
// (e.g. higher iteration count after a SDK upgrade).
|
||||||
|
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
|
||||||
|
{
|
||||||
|
credential.PasswordHash = _passwordHasher.HashPassword(user, request.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
user.LastLoginAt = now;
|
||||||
|
credential.LastUsedAt = now;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Sign the user in directly with the cookie scheme — this is
|
||||||
|
// shaped exactly like the cookie minted by the OIDC callback so
|
||||||
|
// CurrentUserContext, policies and audit can resolve it the
|
||||||
|
// same way (via the userId claim).
|
||||||
|
var http = _httpContextAccessor.HttpContext
|
||||||
|
?? throw new InvalidOperationException("LocalLogin requires an HTTP context.");
|
||||||
|
await SignInAsync(http, user);
|
||||||
|
|
||||||
|
return new LocalLoginResponse(user.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SignInAsync(HttpContext http, User user)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
|
new(CurrentUserContext.UserIdClaimType, user.Id.ToString()),
|
||||||
|
new(ClaimTypes.Email, user.Email),
|
||||||
|
new(ClaimTypes.Name, user.DisplayName),
|
||||||
|
new(ClaimTypes.Role, user.Role.ToString()),
|
||||||
|
};
|
||||||
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-computed PBKDF2 IdentityV3 hash for the constant string "dummy".
|
||||||
|
// Used solely to keep timing similar between "no such user" and
|
||||||
|
// "wrong password" branches; never accepted by production code paths.
|
||||||
|
private const string DummyHash = "AQAAAAIAAYagAAAAEN7N9wzjC0Q5xLsv6T6bC2jD7M5KZ8Z7VlFpYy5UQS3SkQ8Pxn6M8Tcu1gZ6lGqcYg==";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of <c>POST /api/auth/local/login</c>.</summary>
|
||||||
|
/// <param name="UserId">Internal user id (front uses this only as a sanity flag).</param>
|
||||||
|
public sealed record LocalLoginResponse(Guid UserId);
|
||||||
28
src/FamilyNido.Api/Features/Auth/MeDto.cs
Normal file
28
src/FamilyNido.Api/Features/Auth/MeDto.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>Profile payload returned by <c>GET /api/auth/me</c>.</summary>
|
||||||
|
/// <param name="UserId">Internal user identifier.</param>
|
||||||
|
/// <param name="Email">Email associated with the account.</param>
|
||||||
|
/// <param name="DisplayName">Name shown in the UI.</param>
|
||||||
|
/// <param name="Role">Authorization role.</param>
|
||||||
|
/// <param name="FamilyId">Owning family identifier.</param>
|
||||||
|
/// <param name="FamilyName">Owning family display name.</param>
|
||||||
|
/// <param name="MemberId">Linked family member id, if any.</param>
|
||||||
|
/// <param name="MemberDisplayName">Linked family member display name.</param>
|
||||||
|
/// <param name="ColorHex">Linked family member color code.</param>
|
||||||
|
/// <param name="PhotoPath">Relative path to the member's avatar image, if any. Used by the shell to load the photo via <c>/api/family-members/{id}/photo</c>.</param>
|
||||||
|
/// <param name="PreferredLanguage">BCP-47 tag (e.g. <c>es-ES</c>, <c>en-US</c>) the user picked for the UI. Drives email + integration content.</param>
|
||||||
|
public sealed record MeDto(
|
||||||
|
Guid UserId,
|
||||||
|
string Email,
|
||||||
|
string DisplayName,
|
||||||
|
FamilyRole Role,
|
||||||
|
Guid FamilyId,
|
||||||
|
string FamilyName,
|
||||||
|
Guid? MemberId,
|
||||||
|
string? MemberDisplayName,
|
||||||
|
string? ColorHex,
|
||||||
|
string? PhotoPath,
|
||||||
|
string PreferredLanguage);
|
||||||
32
src/FamilyNido.Api/Features/Auth/PasswordPolicy.cs
Normal file
32
src/FamilyNido.Api/Features/Auth/PasswordPolicy.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Centralized password complexity requirements. Plain enough to remember,
|
||||||
|
/// strict enough to keep the obvious "1234" / "password" attacks out.
|
||||||
|
/// Reused by both <see cref="SetLocalPassword"/> and the invitation
|
||||||
|
/// "accept-local" path.
|
||||||
|
/// </summary>
|
||||||
|
internal static class PasswordPolicy
|
||||||
|
{
|
||||||
|
/// <summary>Minimum number of characters.</summary>
|
||||||
|
public const int MinLength = 8;
|
||||||
|
|
||||||
|
/// <summary>Maximum length we ever accept (defends the hash function from absurd inputs).</summary>
|
||||||
|
public const int MaxLength = 256;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rule helper used by FluentValidation builders.
|
||||||
|
/// Requires length, at least one letter, and at least one digit.
|
||||||
|
/// </summary>
|
||||||
|
public static IRuleBuilderOptions<T, string> Password<T>(this IRuleBuilder<T, string> rule)
|
||||||
|
{
|
||||||
|
return rule
|
||||||
|
.NotEmpty()
|
||||||
|
.MinimumLength(MinLength).WithMessage($"Password must be at least {MinLength} characters.")
|
||||||
|
.MaximumLength(MaxLength)
|
||||||
|
.Must(s => s.Any(char.IsLetter)).WithMessage("Password must contain at least one letter.")
|
||||||
|
.Must(s => s.Any(char.IsDigit)).WithMessage("Password must contain at least one digit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/FamilyNido.Api/Features/Auth/Policies.cs
Normal file
39
src/FamilyNido.Api/Features/Auth/Policies.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
using FamilyNido.Api.Features.Integrations;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Named authorization policies used across endpoints. Centralizing them keeps
|
||||||
|
/// policy names discoverable and prevents magic-string drift.
|
||||||
|
/// </summary>
|
||||||
|
public static class Policies
|
||||||
|
{
|
||||||
|
/// <summary>Admin of the family — can manage members and configuration.</summary>
|
||||||
|
public const string Admin = "family.admin";
|
||||||
|
|
||||||
|
/// <summary>Adult — default access level for authenticated users.</summary>
|
||||||
|
public const string Adult = "family.adult";
|
||||||
|
|
||||||
|
/// <summary>Any authenticated and linked user (includes guests).</summary>
|
||||||
|
public const string AuthenticatedUser = "family.member";
|
||||||
|
|
||||||
|
/// <summary>External integration callers authenticated by an API key.</summary>
|
||||||
|
public const string Integration = "family.integration";
|
||||||
|
|
||||||
|
/// <summary>Registers the policies above against the supplied builder.</summary>
|
||||||
|
public static AuthorizationBuilder AddFamilyNidoPolicies(this AuthorizationBuilder builder)
|
||||||
|
{
|
||||||
|
builder
|
||||||
|
.AddPolicy(Admin, p => p.RequireRole(nameof(FamilyRole.Admin)))
|
||||||
|
.AddPolicy(Adult, p => p.RequireRole(nameof(FamilyRole.Admin), nameof(FamilyRole.Adult)))
|
||||||
|
.AddPolicy(AuthenticatedUser, p => p.RequireAuthenticatedUser())
|
||||||
|
.AddPolicy(Integration, p => p
|
||||||
|
.AddAuthenticationSchemes(IntegrationApiKeyDefaults.AuthenticationScheme)
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.RequireClaim(IntegrationClaimTypes.FamilyId)
|
||||||
|
.RequireClaim(IntegrationClaimTypes.AuthorMemberId));
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/FamilyNido.Api/Features/Auth/RemoveCredential.cs
Normal file
62
src/FamilyNido.Api/Features/Auth/RemoveCredential.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: an authenticated user deletes one of their own credentials.
|
||||||
|
/// Refuses to remove the last credential to prevent self-lockout.
|
||||||
|
/// </summary>
|
||||||
|
public static class RemoveCredential
|
||||||
|
{
|
||||||
|
/// <summary>Command.</summary>
|
||||||
|
/// <param name="CredentialId">Credential to remove.</param>
|
||||||
|
public sealed record Command(Guid CredentialId) : IRequest<Result<Unit>>;
|
||||||
|
|
||||||
|
/// <summary>Handler.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var user = await _userContext.GetUserAsync(ct);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "Caller is not authenticated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var credential = await _db.UserCredentials
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == request.CredentialId && c.UserId == user.Id, ct);
|
||||||
|
|
||||||
|
if (credential is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("credential.not_found", "Credential not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalForUser = await _db.UserCredentials.CountAsync(c => c.UserId == user.Id, ct);
|
||||||
|
if (totalForUser <= 1)
|
||||||
|
{
|
||||||
|
return ApplicationError.Conflict(
|
||||||
|
"credential.last_remaining",
|
||||||
|
"Cannot remove the only login method on this account. Add another method first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.UserCredentials.Remove(credential);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/FamilyNido.Api/Features/Auth/SetLocalPassword.cs
Normal file
100
src/FamilyNido.Api/Features/Auth/SetLocalPassword.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Identity;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: an authenticated user sets or rotates their local password.
|
||||||
|
/// First-time setup (no existing local credential) does not require the
|
||||||
|
/// current password; rotation does.
|
||||||
|
/// </summary>
|
||||||
|
public static class SetLocalPassword
|
||||||
|
{
|
||||||
|
/// <summary>Command.</summary>
|
||||||
|
/// <param name="CurrentPassword">Current password (required when rotating; ignored on first-time setup).</param>
|
||||||
|
/// <param name="NewPassword">New password.</param>
|
||||||
|
public sealed record Command(string? CurrentPassword, string NewPassword) : IRequest<Result<Unit>>;
|
||||||
|
|
||||||
|
/// <summary>Validator — only checks the new password's complexity here. Current-password verification happens in the handler against the stored hash.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator with all rules registered.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.NewPassword).Password();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Handler.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
private readonly IPasswordHasher<User> _passwordHasher;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ICurrentUserContext userContext,
|
||||||
|
IPasswordHasher<User> passwordHasher,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
_passwordHasher = passwordHasher;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var user = await _userContext.GetUserAsync(ct);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "Caller is not authenticated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await _db.UserCredentials
|
||||||
|
.FirstOrDefaultAsync(c => c.UserId == user.Id && c.Provider == IdentityProvider.Local, ct);
|
||||||
|
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(request.CurrentPassword))
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation(
|
||||||
|
"auth.current_password_required",
|
||||||
|
"Current password is required to rotate the local credential.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var verification = _passwordHasher.VerifyHashedPassword(
|
||||||
|
user, existing.PasswordHash!, request.CurrentPassword);
|
||||||
|
if (verification == PasswordVerificationResult.Failed)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.invalid_credentials", "Current password is incorrect.");
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.PasswordHash = _passwordHasher.HashPassword(user, request.NewPassword);
|
||||||
|
existing.UpdatedAt = _timeProvider.GetUtcNow();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_db.UserCredentials.Add(new UserCredential
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
Provider = IdentityProvider.Local,
|
||||||
|
PasswordHash = _passwordHasher.HashPassword(user, request.NewPassword),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice for <c>PUT /api/auth/me/preferred-language</c>. Persists the caller's
|
||||||
|
/// chosen UI language so digest emails, mention notifications and integration-
|
||||||
|
/// generated task titles match the bundle they're using on the frontend.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The whitelist of accepted tags lives here on purpose — when a new locale
|
||||||
|
/// is added (frontend bundle + xlf + backend strings) we want the API to
|
||||||
|
/// reject older or unsupported tags loudly instead of silently storing
|
||||||
|
/// garbage that no localizer maps.
|
||||||
|
/// </remarks>
|
||||||
|
public static class UpdateMyPreferredLanguage
|
||||||
|
{
|
||||||
|
/// <summary>Tags accepted by the validator. Keep aligned with the Angular bundles.</summary>
|
||||||
|
public static readonly string[] AllowedTags = { "es-ES", "en-US" };
|
||||||
|
|
||||||
|
/// <summary>Command body carrying the new tag.</summary>
|
||||||
|
/// <param name="Language">BCP-47 tag from <see cref="AllowedTags"/>.</param>
|
||||||
|
public sealed record Command(string Language) : IRequest<Result<Response>>;
|
||||||
|
|
||||||
|
/// <summary>Echo of the persisted value, used by the frontend to confirm.</summary>
|
||||||
|
/// <param name="Language">The new tag stored on the user.</param>
|
||||||
|
public sealed record Response(string Language);
|
||||||
|
|
||||||
|
/// <summary>Validator: ensures the requested tag is one we actually support.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Language)
|
||||||
|
.NotEmpty()
|
||||||
|
.Must(value => Array.IndexOf(AllowedTags, value) >= 0)
|
||||||
|
.WithMessage("Unsupported language tag.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Persists the new preferred language on the caller's <c>User</c> row.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<Response>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<Response>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _userContext.GetUserAsync(cancellationToken);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "Not signed in.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handler only mutates a single column. We re-fetch by id so the
|
||||||
|
// SaveChanges below operates on a tracked entity inside this scope —
|
||||||
|
// GetUserAsync may have returned a no-tracking projection.
|
||||||
|
var tracked = await _db.Users.FirstOrDefaultAsync(u => u.Id == user.Id, cancellationToken);
|
||||||
|
if (tracked is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "User not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracked.PreferredLanguage = request.Language;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new Response(request.Language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/FamilyNido.Api/Features/Calendar/CalendarEndpoints.cs
Normal file
195
src/FamilyNido.Api/Features/Calendar/CalendarEndpoints.cs
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using FamilyNido.Api.Shared.Http;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>REST endpoints for the calendar mirror (RF-CAL-*).</summary>
|
||||||
|
public static class CalendarEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Cookie that round-trips the Google OAuth state between start and callback.</summary>
|
||||||
|
public const string OAuthStateCookieName = "FamilyNido.calendar.google.oauth-state";
|
||||||
|
|
||||||
|
/// <summary>Path under which the state cookie is valid.</summary>
|
||||||
|
public const string OAuthCookiePath = "/api/calendar/google";
|
||||||
|
|
||||||
|
/// <summary>Registers <c>/api/calendar</c> endpoints on the given route group.</summary>
|
||||||
|
public static IEndpointRouteBuilder MapCalendarEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var authenticated = app.MapGroup("/api/calendar")
|
||||||
|
.WithTags("Calendar")
|
||||||
|
.RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
|
||||||
|
authenticated.MapGet("/events", ListEventsAsync);
|
||||||
|
authenticated.MapPut("/events/{eventId:guid}/members", SetEventMembersAsync);
|
||||||
|
authenticated.MapGet("/accounts", ListAccountsAsync);
|
||||||
|
authenticated.MapPatch("/calendars/{id:guid}", UpdateCalendarAsync);
|
||||||
|
authenticated.MapDelete("/accounts/{id:guid}", UnlinkAccountAsync);
|
||||||
|
authenticated.MapPost("/accounts/{id:guid}/sync", SyncAccountAsync);
|
||||||
|
authenticated.MapPost("/google/start", StartLinkAsync);
|
||||||
|
|
||||||
|
// Callback is open: Google redirects the browser here after the user grants
|
||||||
|
// consent. The session cookie is presented automatically (SameSite=Lax) and
|
||||||
|
// the state cookie is what authenticates the dance — not the API auth cookie.
|
||||||
|
// We still want the user to be authenticated to FamilyNido though, so the slice
|
||||||
|
// re-validates the user identity inside.
|
||||||
|
app.MapGet("/api/calendar/google/callback", HandleCallbackAsync)
|
||||||
|
.WithTags("Calendar");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListEventsAsync(
|
||||||
|
DateTimeOffset from,
|
||||||
|
DateTimeOffset to,
|
||||||
|
Guid[]? memberIds,
|
||||||
|
IValidator<ListEvents.Query> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var query = new ListEvents.Query(from, to, memberIds);
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(query, ct);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await mediator.SendAsync(query, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListAccountsAsync(IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new ListLinkedAccounts.Query(), ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> SetEventMembersAsync(
|
||||||
|
Guid eventId,
|
||||||
|
SetCalendarEventMembersBody body,
|
||||||
|
IValidator<SetCalendarEventMembers.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var command = new SetCalendarEventMembers.Command(eventId, body.MemberIds ?? []);
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateCalendarAsync(
|
||||||
|
Guid id,
|
||||||
|
UpdateLinkedCalendarBody body,
|
||||||
|
IValidator<UpdateLinkedCalendar.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var command = new UpdateLinkedCalendar.Command(id, body.IsImported, body.FamilyMemberId);
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UnlinkAccountAsync(Guid id, IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new UnlinkGoogleAccount.Command(id), ct);
|
||||||
|
return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> SyncAccountAsync(Guid id, IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new TriggerManualSync.Command(id), ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> StartLinkAsync(
|
||||||
|
IMediator mediator,
|
||||||
|
HttpContext httpContext,
|
||||||
|
IOptions<CalendarOptions> options,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new StartGoogleLink.Command(), ct);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
return result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the encrypted state into a short-lived cookie scoped to /api/calendar/google
|
||||||
|
// so subsequent callback hits carry it back; SameSite=Lax is required for the
|
||||||
|
// top-level redirect from Google to attach the cookie.
|
||||||
|
httpContext.Response.Cookies.Append(OAuthStateCookieName, result.Value.EncryptedState, new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = httpContext.Request.IsHttps,
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
Path = OAuthCookiePath,
|
||||||
|
Expires = result.Value.ExpiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Results.Ok(new { authUrl = result.Value.AuthUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleCallbackAsync(
|
||||||
|
string? code,
|
||||||
|
string? state,
|
||||||
|
string? error,
|
||||||
|
IMediator mediator,
|
||||||
|
HttpContext httpContext,
|
||||||
|
IOptions<CalendarOptions> options,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var redirectBase = options.Value.PostAuthRedirectPath;
|
||||||
|
|
||||||
|
// Always wipe the state cookie so an aborted attempt doesn't linger.
|
||||||
|
httpContext.Response.Cookies.Delete(OAuthStateCookieName, new CookieOptions { Path = OAuthCookiePath });
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(error))
|
||||||
|
{
|
||||||
|
return Results.Redirect(AppendError(redirectBase, error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(code))
|
||||||
|
{
|
||||||
|
return Results.Redirect(AppendError(redirectBase, "missing_code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var encryptedState = httpContext.Request.Cookies[OAuthStateCookieName];
|
||||||
|
var command = new HandleGoogleCallback.Command(code, state, encryptedState);
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
return Results.Redirect(AppendError(redirectBase, result.Error.Code));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Redirect(redirectBase + "?linked=" + result.Value.GoogleAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string AppendError(string basePath, string code)
|
||||||
|
{
|
||||||
|
var separator = basePath.Contains('?') ? '&' : '?';
|
||||||
|
return $"{basePath}{separator}error={Uri.EscapeDataString(code)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wire-level body for PATCH /api/calendar/calendars/{id}.</summary>
|
||||||
|
/// <param name="IsImported">Whether to mirror events from this calendar.</param>
|
||||||
|
/// <param name="FamilyMemberId">Optional family member to associate (null clears).</param>
|
||||||
|
public sealed record UpdateLinkedCalendarBody(bool IsImported, Guid? FamilyMemberId);
|
||||||
|
|
||||||
|
/// <summary>Wire-level body for PUT /api/calendar/events/{eventId}/members.</summary>
|
||||||
|
/// <param name="MemberIds">New full set of related members (replaces the previous one). Empty array clears all relations.</param>
|
||||||
|
public sealed record SetCalendarEventMembersBody(IReadOnlyList<Guid>? MemberIds);
|
||||||
47
src/FamilyNido.Api/Features/Calendar/CalendarEventDto.cs
Normal file
47
src/FamilyNido.Api/Features/Calendar/CalendarEventDto.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
using FamilyNido.Domain.Calendar;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>Wire-level view of a mirrored Google Calendar event.</summary>
|
||||||
|
/// <param name="Id">FamilyNido id of the event row.</param>
|
||||||
|
/// <param name="LinkedCalendarId">Calendar of origin within the linked account.</param>
|
||||||
|
/// <param name="FamilyMemberId">Family member associated to the source calendar (if any) — drives the color in the UI.</param>
|
||||||
|
/// <param name="Title">Event title.</param>
|
||||||
|
/// <param name="Description">Optional longer description.</param>
|
||||||
|
/// <param name="Location">Optional location string.</param>
|
||||||
|
/// <param name="StartAt">Start instant in UTC.</param>
|
||||||
|
/// <param name="EndAt">End instant in UTC.</param>
|
||||||
|
/// <param name="IsAllDay">True when the event has no specific time-of-day.</param>
|
||||||
|
/// <param name="OriginalTimeZone">Original IANA timezone reported by Google.</param>
|
||||||
|
/// <param name="HtmlLink">Public link back to the event in Google Calendar.</param>
|
||||||
|
/// <param name="RelatedMemberIds">Per-event N:M of family members tagged locally on this event (independent of the calendar default).</param>
|
||||||
|
public sealed record CalendarEventDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid LinkedCalendarId,
|
||||||
|
Guid? FamilyMemberId,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
string? Location,
|
||||||
|
DateTimeOffset StartAt,
|
||||||
|
DateTimeOffset EndAt,
|
||||||
|
bool IsAllDay,
|
||||||
|
string? OriginalTimeZone,
|
||||||
|
string? HtmlLink,
|
||||||
|
IReadOnlyList<Guid> RelatedMemberIds)
|
||||||
|
{
|
||||||
|
/// <summary>Builds a DTO from the persisted entity (assumes <see cref="CalendarEvent.LinkedCalendar"/> and RelatedMembers are loaded).</summary>
|
||||||
|
public static CalendarEventDto From(CalendarEvent ev)
|
||||||
|
=> new(
|
||||||
|
ev.Id,
|
||||||
|
ev.LinkedCalendarId,
|
||||||
|
ev.LinkedCalendar?.FamilyMemberId,
|
||||||
|
ev.Title,
|
||||||
|
ev.Description,
|
||||||
|
ev.Location,
|
||||||
|
ev.StartAt,
|
||||||
|
ev.EndAt,
|
||||||
|
ev.IsAllDay,
|
||||||
|
ev.OriginalTimeZone,
|
||||||
|
ev.HtmlLink,
|
||||||
|
[.. ev.RelatedMembers.Select(m => m.Id)]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hosted service that runs <see cref="CalendarSynchronizer.SyncAllAsync"/> on a
|
||||||
|
/// fixed cadence (<see cref="CalendarOptions.SyncInterval"/>). Sleeps with a
|
||||||
|
/// <see cref="PeriodicTimer"/> so the loop wakes up promptly on shutdown.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CalendarSyncBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IOptionsMonitor<CalendarOptions> _options;
|
||||||
|
private readonly ILogger<CalendarSyncBackgroundService> _logger;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public CalendarSyncBackgroundService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptionsMonitor<CalendarOptions> options,
|
||||||
|
ILogger<CalendarSyncBackgroundService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_options = options;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var initialDelay = TimeSpan.FromSeconds(30);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Don't pile work onto a cold-starting app. A short head-start means the API is
|
||||||
|
// already serving traffic when the first sync runs.
|
||||||
|
await Task.Delay(initialDelay, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await SyncOnceAsync(stoppingToken);
|
||||||
|
|
||||||
|
var interval = _options.CurrentValue.SyncInterval;
|
||||||
|
if (interval <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
interval = TimeSpan.FromMinutes(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(interval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SyncOnceAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var synchronizer = scope.ServiceProvider.GetRequiredService<CalendarSynchronizer>();
|
||||||
|
await synchronizer.SyncAllAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Shutting down; do not log.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Calendar sync iteration failed at the top level.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
319
src/FamilyNido.Api/Features/Calendar/CalendarSynchronizer.cs
Normal file
319
src/FamilyNido.Api/Features/Calendar/CalendarSynchronizer.cs
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
using FamilyNido.Domain.Calendar;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Google.Apis.Calendar.v3.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synchronization engine for the calendar mirror. Talks to Google through
|
||||||
|
/// <see cref="GoogleCalendarClient"/>, upserts into <see cref="ApplicationDbContext"/>,
|
||||||
|
/// and persists the <c>nextSyncToken</c> so subsequent runs do an incremental delta.
|
||||||
|
/// Reused by both the periodic background service and the manual-sync endpoint.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CalendarSynchronizer
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly GoogleOAuthService _oauth;
|
||||||
|
private readonly GoogleCalendarClient _client;
|
||||||
|
private readonly TimeProvider _clock;
|
||||||
|
private readonly ILogger<CalendarSynchronizer> _logger;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public CalendarSynchronizer(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
GoogleOAuthService oauth,
|
||||||
|
GoogleCalendarClient client,
|
||||||
|
TimeProvider clock,
|
||||||
|
ILogger<CalendarSynchronizer> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_oauth = oauth;
|
||||||
|
_client = client;
|
||||||
|
_clock = clock;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a sync for every imported calendar of <paramref name="accountId"/>. Used
|
||||||
|
/// by the manual-sync endpoint to trigger an account-scoped refresh on demand.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SyncAccountAsync(Guid accountId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var account = await _db.GoogleAccounts
|
||||||
|
.Include(a => a.Calendars)
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == accountId, cancellationToken);
|
||||||
|
|
||||||
|
if (account is null || account.IsRevoked)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SyncAccountInternalAsync(account, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sweeps every healthy account and calendar. Called by the background timer.
|
||||||
|
/// Errors on a single calendar are isolated so that one failing account does
|
||||||
|
/// not block the rest.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SyncAllAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accounts = await _db.GoogleAccounts
|
||||||
|
.Include(a => a.Calendars)
|
||||||
|
.Where(a => !a.IsRevoked)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SyncAccountInternalAsync(account, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Sync failed for Google account {AccountId} ({Email})", account.Id, account.Email);
|
||||||
|
account.LastError = ex.Message;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SyncAccountInternalAsync(GoogleAccount account, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string refreshToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
refreshToken = _oauth.UnprotectRefreshToken(account.EncryptedRefreshToken);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
account.IsRevoked = true;
|
||||||
|
account.LastError = "Stored refresh token could not be decrypted; re-link required.";
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe the credential by refreshing once so we fail fast on revoked tokens
|
||||||
|
// before iterating calendars.
|
||||||
|
var refresh = await _oauth.RefreshAccessTokenAsync(refreshToken, cancellationToken);
|
||||||
|
if (refresh is null)
|
||||||
|
{
|
||||||
|
account.IsRevoked = true;
|
||||||
|
account.LastError = "Google rejected the refresh token (invalid_grant).";
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
account.LastError = null;
|
||||||
|
|
||||||
|
var imported = account.Calendars.Where(c => c.IsImported).ToList();
|
||||||
|
foreach (var calendar in imported)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SyncCalendarAsync(calendar, refreshToken, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SyncCalendarAsync(LinkedCalendar calendar, string refreshToken, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GoogleEventsPage page;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
page = await _client.ListEventsAsync(
|
||||||
|
refreshToken,
|
||||||
|
calendar.ExternalCalendarId,
|
||||||
|
calendar.SyncToken,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "events.list failed for calendar {CalendarId}", calendar.ExternalCalendarId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.FullSyncRequired)
|
||||||
|
{
|
||||||
|
// Sync token expired (Gone). Drop everything we had and let the next tick run a full sync.
|
||||||
|
await _db.CalendarEvents
|
||||||
|
.Where(e => e.LinkedCalendarId == calendar.Id)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
calendar.SyncToken = null;
|
||||||
|
calendar.LastSyncedAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var familyId = await ResolveFamilyIdAsync(calendar, cancellationToken);
|
||||||
|
|
||||||
|
var existingByExternalId = await _db.CalendarEvents
|
||||||
|
.Where(e => e.LinkedCalendarId == calendar.Id)
|
||||||
|
.ToDictionaryAsync(e => e.ExternalEventId, StringComparer.Ordinal, cancellationToken);
|
||||||
|
|
||||||
|
foreach (var googleEvent in page.Events)
|
||||||
|
{
|
||||||
|
ApplyEvent(googleEvent, calendar, familyId, existingByExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(page.NextSyncToken))
|
||||||
|
{
|
||||||
|
calendar.SyncToken = page.NextSyncToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar.LastSyncedAt = _clock.GetUtcNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid> ResolveFamilyIdAsync(LinkedCalendar calendar, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// The calendar's owning account is the source of truth for FamilyId. Pull it
|
||||||
|
// explicitly: relying on calendar.GoogleAccount being attached requires Include.
|
||||||
|
if (calendar.GoogleAccount is { } loaded)
|
||||||
|
{
|
||||||
|
return loaded.FamilyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _db.GoogleAccounts
|
||||||
|
.Where(a => a.Id == calendar.GoogleAccountId)
|
||||||
|
.Select(a => a.FamilyId)
|
||||||
|
.FirstAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyEvent(
|
||||||
|
Event googleEvent,
|
||||||
|
LinkedCalendar calendar,
|
||||||
|
Guid familyId,
|
||||||
|
Dictionary<string, CalendarEvent> existing)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(googleEvent.Id))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// status=cancelled means the event was deleted in Google. We mirror that by
|
||||||
|
// removing the row.
|
||||||
|
if (string.Equals(googleEvent.Status, "cancelled", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (existing.TryGetValue(googleEvent.Id, out var doomed))
|
||||||
|
{
|
||||||
|
_db.CalendarEvents.Remove(doomed);
|
||||||
|
existing.Remove(googleEvent.Id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryReadInstants(googleEvent, out var startAt, out var endAt, out var isAllDay, out var timeZone))
|
||||||
|
{
|
||||||
|
// Events without start/end are valid in Google's data model (e.g. tasks),
|
||||||
|
// but the calendar UI cannot render them. Skip silently.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.TryGetValue(googleEvent.Id, out var row))
|
||||||
|
{
|
||||||
|
row.Title = googleEvent.Summary ?? "(sin título)";
|
||||||
|
row.Description = googleEvent.Description;
|
||||||
|
row.Location = googleEvent.Location;
|
||||||
|
row.StartAt = startAt;
|
||||||
|
row.EndAt = endAt;
|
||||||
|
row.IsAllDay = isAllDay;
|
||||||
|
row.OriginalTimeZone = timeZone;
|
||||||
|
row.HtmlLink = googleEvent.HtmlLink;
|
||||||
|
row.IcalUid = googleEvent.ICalUID;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_db.CalendarEvents.Add(new CalendarEvent
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
LinkedCalendarId = calendar.Id,
|
||||||
|
ExternalEventId = googleEvent.Id,
|
||||||
|
IcalUid = googleEvent.ICalUID,
|
||||||
|
Title = googleEvent.Summary ?? "(sin título)",
|
||||||
|
Description = googleEvent.Description,
|
||||||
|
Location = googleEvent.Location,
|
||||||
|
StartAt = startAt,
|
||||||
|
EndAt = endAt,
|
||||||
|
IsAllDay = isAllDay,
|
||||||
|
OriginalTimeZone = timeZone,
|
||||||
|
HtmlLink = googleEvent.HtmlLink,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadInstants(
|
||||||
|
Event googleEvent,
|
||||||
|
out DateTimeOffset startAt,
|
||||||
|
out DateTimeOffset endAt,
|
||||||
|
out bool isAllDay,
|
||||||
|
out string? timeZone)
|
||||||
|
{
|
||||||
|
startAt = default;
|
||||||
|
endAt = default;
|
||||||
|
isAllDay = false;
|
||||||
|
timeZone = googleEvent.Start?.TimeZone ?? googleEvent.End?.TimeZone;
|
||||||
|
|
||||||
|
if (googleEvent.Start is null || googleEvent.End is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (googleEvent.Start.DateTimeDateTimeOffset is { } startDateTime &&
|
||||||
|
googleEvent.End.DateTimeDateTimeOffset is { } endDateTime)
|
||||||
|
{
|
||||||
|
// Npgsql's timestamptz requires Offset=00:00, so normalize the original
|
||||||
|
// event offset (Google echoes whatever the author had) to UTC.
|
||||||
|
startAt = startDateTime.ToUniversalTime();
|
||||||
|
endAt = endDateTime.ToUniversalTime();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(googleEvent.Start.Date) && !string.IsNullOrEmpty(googleEvent.End.Date))
|
||||||
|
{
|
||||||
|
// All-day events come as YYYY-MM-DD strings. Treat them as midnight in the
|
||||||
|
// event's original timezone, falling back to UTC. End is exclusive (Google convention).
|
||||||
|
isAllDay = true;
|
||||||
|
var tz = TryFindTimeZone(timeZone) ?? TimeZoneInfo.Utc;
|
||||||
|
startAt = ParseAllDay(googleEvent.Start.Date, tz).ToUniversalTime();
|
||||||
|
endAt = ParseAllDay(googleEvent.End.Date, tz).ToUniversalTime();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset ParseAllDay(string yyyyMmDd, TimeZoneInfo tz)
|
||||||
|
{
|
||||||
|
var date = DateOnly.Parse(yyyyMmDd, System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
var local = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified);
|
||||||
|
var offset = tz.GetUtcOffset(local);
|
||||||
|
return new DateTimeOffset(local, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeZoneInfo? TryFindTimeZone(string? id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/FamilyNido.Api/Features/Calendar/GoogleAccountDto.cs
Normal file
66
src/FamilyNido.Api/Features/Calendar/GoogleAccountDto.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
using FamilyNido.Domain.Calendar;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>Wire-level view of a linked Google account plus its discovered calendars.</summary>
|
||||||
|
/// <param name="Id">FamilyNido id of the account row.</param>
|
||||||
|
/// <param name="Email">Google email — labels the account in the UI.</param>
|
||||||
|
/// <param name="DisplayName">Best-effort display name reported by Google.</param>
|
||||||
|
/// <param name="IsRevoked">True when the refresh token was rejected; the user must re-link.</param>
|
||||||
|
/// <param name="LastError">Last sync error message captured for diagnostics; null when healthy.</param>
|
||||||
|
/// <param name="LinkedAt">UTC instant the account was linked.</param>
|
||||||
|
/// <param name="Calendars">Calendars discovered under this account.</param>
|
||||||
|
public sealed record GoogleAccountDto(
|
||||||
|
Guid Id,
|
||||||
|
string Email,
|
||||||
|
string? DisplayName,
|
||||||
|
bool IsRevoked,
|
||||||
|
string? LastError,
|
||||||
|
DateTimeOffset LinkedAt,
|
||||||
|
IReadOnlyList<LinkedCalendarDto> Calendars)
|
||||||
|
{
|
||||||
|
/// <summary>Builds a DTO from the persisted entity (with eager-loaded calendars).</summary>
|
||||||
|
public static GoogleAccountDto From(GoogleAccount account)
|
||||||
|
=> new(
|
||||||
|
account.Id,
|
||||||
|
account.Email,
|
||||||
|
account.DisplayName,
|
||||||
|
account.IsRevoked,
|
||||||
|
account.LastError,
|
||||||
|
account.CreatedAt,
|
||||||
|
[.. account.Calendars
|
||||||
|
.OrderBy(c => c.Summary, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(LinkedCalendarDto.From)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wire-level view of a single Google calendar discovered under an account.</summary>
|
||||||
|
/// <param name="Id">FamilyNido id of the linked-calendar row.</param>
|
||||||
|
/// <param name="ExternalCalendarId">Google's calendar id (stable across accounts).</param>
|
||||||
|
/// <param name="Summary">Display name from Google.</param>
|
||||||
|
/// <param name="Description">Optional description from Google.</param>
|
||||||
|
/// <param name="ColorHex">Color reported by Google (<c>#RRGGBB</c>).</param>
|
||||||
|
/// <param name="IsImported">Whether events from this calendar are mirrored.</param>
|
||||||
|
/// <param name="FamilyMemberId">Optional family member assigned for color attribution.</param>
|
||||||
|
/// <param name="LastSyncedAt">UTC instant of the last successful sync; null until first sync.</param>
|
||||||
|
public sealed record LinkedCalendarDto(
|
||||||
|
Guid Id,
|
||||||
|
string ExternalCalendarId,
|
||||||
|
string Summary,
|
||||||
|
string? Description,
|
||||||
|
string? ColorHex,
|
||||||
|
bool IsImported,
|
||||||
|
Guid? FamilyMemberId,
|
||||||
|
DateTimeOffset? LastSyncedAt)
|
||||||
|
{
|
||||||
|
/// <summary>Builds a DTO from the persisted entity.</summary>
|
||||||
|
public static LinkedCalendarDto From(LinkedCalendar calendar)
|
||||||
|
=> new(
|
||||||
|
calendar.Id,
|
||||||
|
calendar.ExternalCalendarId,
|
||||||
|
calendar.Summary,
|
||||||
|
calendar.Description,
|
||||||
|
calendar.ColorHex,
|
||||||
|
calendar.IsImported,
|
||||||
|
calendar.FamilyMemberId,
|
||||||
|
calendar.LastSyncedAt);
|
||||||
|
}
|
||||||
175
src/FamilyNido.Api/Features/Calendar/GoogleCalendarClient.cs
Normal file
175
src/FamilyNido.Api/Features/Calendar/GoogleCalendarClient.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using Google.Apis.Auth.OAuth2;
|
||||||
|
using Google.Apis.Auth.OAuth2.Flows;
|
||||||
|
using Google.Apis.Auth.OAuth2.Responses;
|
||||||
|
using Google.Apis.Calendar.v3;
|
||||||
|
using Google.Apis.Calendar.v3.Data;
|
||||||
|
using Google.Apis.Services;
|
||||||
|
using Google.Apis.Util.Store;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin wrapper around the official <c>Google.Apis.Calendar.v3</c> SDK that hides
|
||||||
|
/// the credential plumbing. Each call accepts a refresh token and builds a fresh
|
||||||
|
/// in-memory <see cref="UserCredential"/> — no on-disk token cache, no global state.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GoogleCalendarClient
|
||||||
|
{
|
||||||
|
private readonly IOptions<CalendarOptions> _options;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public GoogleCalendarClient(IOptions<CalendarOptions> options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists every calendar visible to the linked Google account.</summary>
|
||||||
|
/// <param name="refreshToken">Plaintext refresh token of the linked account.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation propagated from the caller.</param>
|
||||||
|
/// <returns>Calendars as Google reports them — caller decides which to mirror.</returns>
|
||||||
|
public async Task<IReadOnlyList<CalendarListEntry>> ListCalendarsAsync(string refreshToken, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var service = BuildService(refreshToken);
|
||||||
|
var request = service.CalendarList.List();
|
||||||
|
request.MinAccessRole = CalendarListResource.ListRequest.MinAccessRoleEnum.Reader;
|
||||||
|
|
||||||
|
var aggregated = new List<CalendarListEntry>();
|
||||||
|
string? pageToken = null;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
request.PageToken = pageToken;
|
||||||
|
var page = await request.ExecuteAsync(cancellationToken);
|
||||||
|
if (page.Items is not null)
|
||||||
|
{
|
||||||
|
aggregated.AddRange(page.Items);
|
||||||
|
}
|
||||||
|
pageToken = page.NextPageToken;
|
||||||
|
} while (!string.IsNullOrEmpty(pageToken));
|
||||||
|
|
||||||
|
return aggregated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pages through <c>events.list</c> for a single calendar. When
|
||||||
|
/// <paramref name="syncToken"/> is non-null, performs an incremental sync; when
|
||||||
|
/// null, performs a full sync within the configured lookback/lookahead window.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="refreshToken">Plaintext refresh token of the linked account.</param>
|
||||||
|
/// <param name="externalCalendarId">Google calendar id to enumerate.</param>
|
||||||
|
/// <param name="syncToken">Latest <c>nextSyncToken</c> received, or null for a full sync.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation propagated from the caller.</param>
|
||||||
|
public async Task<GoogleEventsPage> ListEventsAsync(
|
||||||
|
string refreshToken,
|
||||||
|
string externalCalendarId,
|
||||||
|
string? syncToken,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var service = BuildService(refreshToken);
|
||||||
|
|
||||||
|
var aggregated = new List<Event>();
|
||||||
|
string? pageToken = null;
|
||||||
|
string? nextSyncToken = null;
|
||||||
|
var fullSyncRequired = false;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var request = service.Events.List(externalCalendarId);
|
||||||
|
request.PageToken = pageToken;
|
||||||
|
// Critical: SingleEvents=true makes Google expand recurrences into individual instances,
|
||||||
|
// so the FamilyNido sync engine never has to deal with RRULE expansion.
|
||||||
|
request.SingleEvents = true;
|
||||||
|
request.ShowDeleted = !string.IsNullOrEmpty(syncToken); // incremental sync needs deletes
|
||||||
|
request.MaxResults = 250;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(syncToken))
|
||||||
|
{
|
||||||
|
request.SyncToken = syncToken;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
request.TimeMinDateTimeOffset = now.AddTicks(-_options.Value.FullSyncLookback.Ticks);
|
||||||
|
request.TimeMaxDateTimeOffset = now.AddTicks(_options.Value.FullSyncLookahead.Ticks);
|
||||||
|
request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = await request.ExecuteAsync(cancellationToken);
|
||||||
|
if (page.Items is not null)
|
||||||
|
{
|
||||||
|
aggregated.AddRange(page.Items);
|
||||||
|
}
|
||||||
|
pageToken = page.NextPageToken;
|
||||||
|
nextSyncToken = page.NextSyncToken ?? nextSyncToken;
|
||||||
|
}
|
||||||
|
catch (Google.GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Gone)
|
||||||
|
{
|
||||||
|
// 410 Gone = the sync token has expired; signal the caller to drop it
|
||||||
|
// and run a full sync next iteration.
|
||||||
|
fullSyncRequired = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} while (!string.IsNullOrEmpty(pageToken));
|
||||||
|
|
||||||
|
return new GoogleEventsPage(aggregated, nextSyncToken, fullSyncRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CalendarService BuildService(string refreshToken)
|
||||||
|
{
|
||||||
|
var options = _options.Value;
|
||||||
|
var flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer
|
||||||
|
{
|
||||||
|
ClientSecrets = new ClientSecrets
|
||||||
|
{
|
||||||
|
ClientId = options.GoogleClientId,
|
||||||
|
ClientSecret = options.GoogleClientSecret,
|
||||||
|
},
|
||||||
|
Scopes = [CalendarService.Scope.CalendarReadonly],
|
||||||
|
DataStore = new NullDataStore(),
|
||||||
|
});
|
||||||
|
|
||||||
|
var token = new TokenResponse
|
||||||
|
{
|
||||||
|
RefreshToken = refreshToken,
|
||||||
|
// Force the SDK to fetch a fresh access token on first use.
|
||||||
|
ExpiresInSeconds = 0,
|
||||||
|
IssuedUtc = DateTime.UtcNow.AddDays(-1),
|
||||||
|
};
|
||||||
|
|
||||||
|
var credential = new UserCredential(flow, "user", token);
|
||||||
|
|
||||||
|
return new CalendarService(new BaseClientService.Initializer
|
||||||
|
{
|
||||||
|
HttpClientInitializer = credential,
|
||||||
|
ApplicationName = "FamilyNido",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory "data store" so the SDK doesn't try to persist tokens on disk.
|
||||||
|
/// We hold tokens ourselves (encrypted in Postgres) and rebuild the credential
|
||||||
|
/// per request.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class NullDataStore : IDataStore
|
||||||
|
{
|
||||||
|
public Task ClearAsync() => Task.CompletedTask;
|
||||||
|
public Task DeleteAsync<T>(string key) => Task.CompletedTask;
|
||||||
|
public Task<T> GetAsync<T>(string key) => Task.FromResult<T>(default!);
|
||||||
|
public Task StoreAsync<T>(string key, T value) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a <c>events.list</c> page traversal.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Events">All events from every page returned by Google.</param>
|
||||||
|
/// <param name="NextSyncToken">Token to persist for the next incremental sync; null when a full sync is still pending.</param>
|
||||||
|
/// <param name="FullSyncRequired">True when Google replied 410 Gone — the caller must reset its stored sync token and run a full sync next.</param>
|
||||||
|
public sealed record GoogleEventsPage(
|
||||||
|
IReadOnlyList<Event> Events,
|
||||||
|
string? NextSyncToken,
|
||||||
|
bool FullSyncRequired);
|
||||||
217
src/FamilyNido.Api/Features/Calendar/GoogleOAuthService.cs
Normal file
217
src/FamilyNido.Api/Features/Calendar/GoogleOAuthService.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encapsulates the Google OAuth 2.0 Authorization Code dance for the Calendar
|
||||||
|
/// integration: builds the authorization URL with a CSRF-resistant state, exchanges
|
||||||
|
/// the redirected code for tokens, and protects/unprotects the refresh token at
|
||||||
|
/// rest. Stateless — kept as a singleton because it depends only on options and
|
||||||
|
/// Data Protection, both of which are themselves singletons.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GoogleOAuthService
|
||||||
|
{
|
||||||
|
private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||||
|
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
|
||||||
|
private const string CalendarScope = "https://www.googleapis.com/auth/calendar.readonly";
|
||||||
|
private const string OpenIdScope = "openid";
|
||||||
|
private const string EmailScope = "email";
|
||||||
|
private const string ProfileScope = "profile";
|
||||||
|
|
||||||
|
private readonly IDataProtector _stateProtector;
|
||||||
|
private readonly IDataProtector _refreshTokenProtector;
|
||||||
|
private readonly IOptions<CalendarOptions> _options;
|
||||||
|
private readonly TimeProvider _clock;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public GoogleOAuthService(
|
||||||
|
IDataProtectionProvider dataProtectionProvider,
|
||||||
|
IOptions<CalendarOptions> options,
|
||||||
|
TimeProvider clock,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_stateProtector = dataProtectionProvider.CreateProtector("calendar.google.oauth-state");
|
||||||
|
_refreshTokenProtector = dataProtectionProvider.CreateProtector("calendar.google.refresh-token");
|
||||||
|
_options = options;
|
||||||
|
_clock = clock;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the Google authorization URL plus the encrypted state payload that the
|
||||||
|
/// API must round-trip via cookie. The plain nonce is what we hand Google through
|
||||||
|
/// the <c>state</c> query parameter; the encrypted blob lives in a short-lived
|
||||||
|
/// cookie and is re-validated on the callback.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">Authenticated FamilyNido user id.</param>
|
||||||
|
/// <returns>Tuple containing the auth URL, the encrypted cookie payload, and the cookie expiration.</returns>
|
||||||
|
public (string AuthUrl, string EncryptedState, DateTimeOffset ExpiresAt) BuildAuthorizationRequest(Guid userId)
|
||||||
|
{
|
||||||
|
var options = _options.Value;
|
||||||
|
var nonce = Guid.NewGuid().ToString("N");
|
||||||
|
var expiresAt = _clock.GetUtcNow().AddMinutes(15);
|
||||||
|
|
||||||
|
var statePayload = new GoogleOAuthState(userId, nonce, expiresAt);
|
||||||
|
var encrypted = _stateProtector.Protect(JsonSerializer.Serialize(statePayload));
|
||||||
|
|
||||||
|
var query = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["client_id"] = options.GoogleClientId,
|
||||||
|
["redirect_uri"] = options.OAuthRedirectUri,
|
||||||
|
["response_type"] = "code",
|
||||||
|
["scope"] = string.Join(' ', [CalendarScope, OpenIdScope, EmailScope, ProfileScope]),
|
||||||
|
// offline + consent guarantees a refresh_token even if the user has linked before.
|
||||||
|
["access_type"] = "offline",
|
||||||
|
["prompt"] = "consent",
|
||||||
|
["include_granted_scopes"] = "true",
|
||||||
|
["state"] = nonce,
|
||||||
|
};
|
||||||
|
|
||||||
|
var authUrl = $"{AuthorizationEndpoint}?{BuildQuery(query)}";
|
||||||
|
return (authUrl, encrypted, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the encrypted state cookie content against the <c>state</c> query
|
||||||
|
/// parameter Google echoed back. Returns null when the cookie is missing, expired,
|
||||||
|
/// tampered with, or does not match the query nonce.
|
||||||
|
/// </summary>
|
||||||
|
public GoogleOAuthState? ValidateState(string? encryptedCookie, string? queryStateNonce)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(encryptedCookie) || string.IsNullOrEmpty(queryStateNonce))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = _stateProtector.Unprotect(encryptedCookie);
|
||||||
|
var payload = JsonSerializer.Deserialize<GoogleOAuthState>(json);
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.ExpiresAt < _clock.GetUtcNow())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CryptographicEquals(payload.Nonce, queryStateNonce))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Tampering, key rotation, or malformed payload: treat as invalid.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchanges the authorization <paramref name="code"/> received on the callback
|
||||||
|
/// for an access + refresh token pair. Throws on transport or HTTP errors so the
|
||||||
|
/// caller can surface a clean failure reason.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<GoogleTokenResponse> ExchangeCodeAsync(string code, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var options = _options.Value;
|
||||||
|
using var http = _httpClientFactory.CreateClient(nameof(GoogleOAuthService));
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("code", code),
|
||||||
|
new KeyValuePair<string, string>("client_id", options.GoogleClientId),
|
||||||
|
new KeyValuePair<string, string>("client_secret", options.GoogleClientSecret),
|
||||||
|
new KeyValuePair<string, string>("redirect_uri", options.OAuthRedirectUri),
|
||||||
|
new KeyValuePair<string, string>("grant_type", "authorization_code"),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var response = await http.PostAsync(TokenEndpoint, form, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<GoogleTokenResponse>(cancellationToken: cancellationToken);
|
||||||
|
if (payload is null || string.IsNullOrEmpty(payload.AccessToken))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Google token endpoint returned an empty response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes the access token for an account using its stored refresh token.
|
||||||
|
/// Returns null when Google rejects the refresh (revoked credential), so callers
|
||||||
|
/// can flip <c>GoogleAccount.IsRevoked</c> instead of looping forever.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<GoogleTokenResponse?> RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var options = _options.Value;
|
||||||
|
using var http = _httpClientFactory.CreateClient(nameof(GoogleOAuthService));
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("refresh_token", refreshToken),
|
||||||
|
new KeyValuePair<string, string>("client_id", options.GoogleClientId),
|
||||||
|
new KeyValuePair<string, string>("client_secret", options.GoogleClientSecret),
|
||||||
|
new KeyValuePair<string, string>("grant_type", "refresh_token"),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var response = await http.PostAsync(TokenEndpoint, form, cancellationToken);
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest ||
|
||||||
|
response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
// Google returns 400 invalid_grant for a revoked or expired refresh token.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await response.Content.ReadFromJsonAsync<GoogleTokenResponse>(cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encrypts a refresh token for storage in <c>google_accounts.encrypted_refresh_token</c>.</summary>
|
||||||
|
public string ProtectRefreshToken(string plaintext) => _refreshTokenProtector.Protect(plaintext);
|
||||||
|
|
||||||
|
/// <summary>Decrypts a refresh token. Throws if the ciphertext was tampered with or the key was rotated past retention.</summary>
|
||||||
|
public string UnprotectRefreshToken(string ciphertext) => _refreshTokenProtector.Unprotect(ciphertext);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decodes (without verifying — Google already verified by issuing) the email
|
||||||
|
/// and display name from a Google id_token JWT. Used after the code exchange to
|
||||||
|
/// label the linked account in the UI without an extra API call.
|
||||||
|
/// </summary>
|
||||||
|
public static (string Email, string? Name) DecodeIdToken(string idToken)
|
||||||
|
{
|
||||||
|
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(idToken);
|
||||||
|
var email = jwt.Claims.FirstOrDefault(c => c.Type == "email")?.Value
|
||||||
|
?? throw new InvalidOperationException("Google id_token did not contain an email claim.");
|
||||||
|
var name = jwt.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
|
||||||
|
return (email, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CryptographicEquals(string left, string right)
|
||||||
|
{
|
||||||
|
var leftBytes = Encoding.UTF8.GetBytes(left);
|
||||||
|
var rightBytes = Encoding.UTF8.GetBytes(right);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildQuery(IDictionary<string, string?> values)
|
||||||
|
{
|
||||||
|
var pairs = values
|
||||||
|
.Where(kv => kv.Value is not null)
|
||||||
|
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}");
|
||||||
|
return string.Join('&', pairs);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/FamilyNido.Api/Features/Calendar/GoogleOAuthState.cs
Normal file
12
src/FamilyNido.Api/Features/Calendar/GoogleOAuthState.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload stored in the encrypted state cookie during the Google OAuth dance.
|
||||||
|
/// Persisted only for the few minutes between the user clicking "link" and Google
|
||||||
|
/// redirecting back. Bound to the authenticated user so an attacker cannot reuse
|
||||||
|
/// a callback for a different identity.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="UserId">FamilyNido user id that initiated the link.</param>
|
||||||
|
/// <param name="Nonce">Random value also passed to Google as the <c>state</c> query parameter — must round-trip exactly.</param>
|
||||||
|
/// <param name="ExpiresAt">Hard expiration; callbacks beyond this are rejected.</param>
|
||||||
|
public sealed record GoogleOAuthState(Guid UserId, string Nonce, DateTimeOffset ExpiresAt);
|
||||||
33
src/FamilyNido.Api/Features/Calendar/GoogleTokenResponse.cs
Normal file
33
src/FamilyNido.Api/Features/Calendar/GoogleTokenResponse.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token endpoint response from <c>https://oauth2.googleapis.com/token</c>. Only
|
||||||
|
/// the fields we actually consume are surfaced.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GoogleTokenResponse
|
||||||
|
{
|
||||||
|
/// <summary>Short-lived OAuth access token (typically 1 hour).</summary>
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string? AccessToken { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Long-lived refresh token. Only returned on the first consent (or when
|
||||||
|
/// <c>prompt=consent</c> forces re-issuance). Persisted encrypted.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string? RefreshToken { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Identity token containing the verified email and display name.</summary>
|
||||||
|
[JsonPropertyName("id_token")]
|
||||||
|
public string? IdToken { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Token lifetime in seconds.</summary>
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public int ExpiresIn { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Granted scope (space-separated).</summary>
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
public string? Scope { get; init; }
|
||||||
|
}
|
||||||
189
src/FamilyNido.Api/Features/Calendar/HandleGoogleCallback.cs
Normal file
189
src/FamilyNido.Api/Features/Calendar/HandleGoogleCallback.cs
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Calendar;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: process the Google OAuth callback. Validates the state cookie, exchanges
|
||||||
|
/// the authorization code for tokens, persists (or refreshes) the
|
||||||
|
/// <see cref="GoogleAccount"/>, and discovers the visible calendars so the user can
|
||||||
|
/// choose which to import on the cuentas screen.
|
||||||
|
/// </summary>
|
||||||
|
public static class HandleGoogleCallback
|
||||||
|
{
|
||||||
|
/// <summary>Inputs harvested from the callback request.</summary>
|
||||||
|
/// <param name="Code">Authorization code returned by Google.</param>
|
||||||
|
/// <param name="QueryStateNonce">State value Google echoed back via the <c>state</c> query parameter.</param>
|
||||||
|
/// <param name="EncryptedStateCookie">Encrypted payload from the OAuth state cookie.</param>
|
||||||
|
public sealed record Command(
|
||||||
|
string Code,
|
||||||
|
string? QueryStateNonce,
|
||||||
|
string? EncryptedStateCookie) : IRequest<Result<Response>>;
|
||||||
|
|
||||||
|
/// <summary>Outcome surfaced to the redirect handler.</summary>
|
||||||
|
/// <param name="GoogleAccountId">Id of the persisted account row.</param>
|
||||||
|
/// <param name="DiscoveredCalendars">How many calendars were discovered (purely informational).</param>
|
||||||
|
public sealed record Response(Guid GoogleAccountId, int DiscoveredCalendars);
|
||||||
|
|
||||||
|
/// <summary>Handler — runs the OAuth post-back logic.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<Response>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly GoogleOAuthService _oauth;
|
||||||
|
private readonly GoogleCalendarClient _calendarClient;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
GoogleOAuthService oauth,
|
||||||
|
GoogleCalendarClient calendarClient)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_oauth = oauth;
|
||||||
|
_calendarClient = calendarClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<Response>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var state = _oauth.ValidateState(request.EncryptedStateCookie, request.QueryStateNonce);
|
||||||
|
if (state is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation(
|
||||||
|
"calendar.oauth_state_invalid",
|
||||||
|
"El estado OAuth es inválido o ha expirado. Inicia el proceso de vinculación de nuevo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _db.Users
|
||||||
|
.Include(u => u.FamilyMember)
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == state.UserId, cancellationToken);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound(
|
||||||
|
"calendar.user_not_found",
|
||||||
|
"El usuario que inició la vinculación ya no existe.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.FamilyMember is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden(
|
||||||
|
"calendar.user_not_linked",
|
||||||
|
"El usuario debe estar enlazado a un miembro familiar para vincular Google Calendar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
GoogleTokenResponse tokens;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tokens = await _oauth.ExchangeCodeAsync(request.Code, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation(
|
||||||
|
"calendar.token_exchange_failed",
|
||||||
|
"Google rechazó el intercambio de código. Inténtalo de nuevo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(tokens.RefreshToken) || string.IsNullOrEmpty(tokens.IdToken))
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation(
|
||||||
|
"calendar.missing_refresh_token",
|
||||||
|
"Google no devolvió un refresh token. Revoca el acceso en tu cuenta y vuelve a intentarlo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
(string Email, string? Name) identity;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
identity = GoogleOAuthService.DecodeIdToken(tokens.IdToken);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation(
|
||||||
|
"calendar.invalid_id_token",
|
||||||
|
"Google devolvió un id_token con formato inesperado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either reuse an existing link (refresh token rotation) or insert a new one.
|
||||||
|
var account = await _db.GoogleAccounts
|
||||||
|
.Include(a => a.Calendars)
|
||||||
|
.FirstOrDefaultAsync(a => a.UserId == user.Id && a.Email == identity.Email, cancellationToken);
|
||||||
|
|
||||||
|
if (account is null)
|
||||||
|
{
|
||||||
|
account = new GoogleAccount
|
||||||
|
{
|
||||||
|
FamilyId = user.FamilyMember.FamilyId,
|
||||||
|
UserId = user.Id,
|
||||||
|
Email = identity.Email,
|
||||||
|
DisplayName = identity.Name,
|
||||||
|
EncryptedRefreshToken = _oauth.ProtectRefreshToken(tokens.RefreshToken),
|
||||||
|
IsRevoked = false,
|
||||||
|
LastError = null,
|
||||||
|
};
|
||||||
|
_db.GoogleAccounts.Add(account);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
account.EncryptedRefreshToken = _oauth.ProtectRefreshToken(tokens.RefreshToken);
|
||||||
|
account.DisplayName = identity.Name;
|
||||||
|
account.IsRevoked = false;
|
||||||
|
account.LastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover calendars now so the cuentas UI has something to render right
|
||||||
|
// after the redirect. New calendars are added; pre-existing ones keep their
|
||||||
|
// IsImported / FamilyMemberId / SyncToken state.
|
||||||
|
IReadOnlyList<Google.Apis.Calendar.v3.Data.CalendarListEntry> discovered;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
discovered = await _calendarClient.ListCalendarsAsync(tokens.RefreshToken, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
account.LastError = $"calendarList.list failed: {ex.Message}";
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return ApplicationError.Validation(
|
||||||
|
"calendar.list_failed",
|
||||||
|
"No se pudo recuperar la lista de calendarios. Revisa los permisos otorgados a la app.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingByExternalId = account.Calendars
|
||||||
|
.ToDictionary(c => c.ExternalCalendarId, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var entry in discovered)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(entry.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingByExternalId.TryGetValue(entry.Id, out var existing))
|
||||||
|
{
|
||||||
|
existing.Summary = entry.Summary ?? existing.Summary;
|
||||||
|
existing.Description = entry.Description ?? existing.Description;
|
||||||
|
existing.ColorHex = entry.BackgroundColor ?? existing.ColorHex;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
account.Calendars.Add(new LinkedCalendar
|
||||||
|
{
|
||||||
|
GoogleAccountId = account.Id,
|
||||||
|
ExternalCalendarId = entry.Id,
|
||||||
|
Summary = entry.Summary ?? entry.Id,
|
||||||
|
Description = entry.Description,
|
||||||
|
ColorHex = entry.BackgroundColor,
|
||||||
|
IsImported = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new Response(account.Id, account.Calendars.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/FamilyNido.Api/Features/Calendar/ListEvents.cs
Normal file
92
src/FamilyNido.Api/Features/Calendar/ListEvents.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: read events for the family calendar within a time window. Filters cover
|
||||||
|
/// the day/week/month/agenda views by adjusting <c>from</c>/<c>to</c> on the client side.
|
||||||
|
/// </summary>
|
||||||
|
public static class ListEvents
|
||||||
|
{
|
||||||
|
/// <summary>Query — half-open time window, optional member filter.</summary>
|
||||||
|
/// <param name="From">Inclusive lower bound (UTC).</param>
|
||||||
|
/// <param name="To">Exclusive upper bound (UTC).</param>
|
||||||
|
/// <param name="MemberIds">Optional set of family member ids; null/empty means "all members".</param>
|
||||||
|
public sealed record Query(
|
||||||
|
DateTimeOffset From,
|
||||||
|
DateTimeOffset To,
|
||||||
|
IReadOnlyList<Guid>? MemberIds) : IRequest<Result<IReadOnlyList<CalendarEventDto>>>;
|
||||||
|
|
||||||
|
/// <summary>Input validation.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Query>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.To).GreaterThan(x => x.From);
|
||||||
|
|
||||||
|
RuleFor(x => x.To.Subtract(x.From))
|
||||||
|
.LessThanOrEqualTo(TimeSpan.FromDays(370))
|
||||||
|
.WithMessage("Time window cannot exceed 370 days.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Handler — runs the family-scoped range query.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Query, Result<IReadOnlyList<CalendarEventDto>>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<IReadOnlyList<CalendarEventDto>>> HandleAsync(Query request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard "overlap" predicate: events where start < to AND end > from.
|
||||||
|
var events = _db.CalendarEvents
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(e => e.LinkedCalendar)
|
||||||
|
.Include(e => e.RelatedMembers)
|
||||||
|
.Where(e => e.FamilyId == current.Family.Id)
|
||||||
|
.Where(e => e.StartAt < request.To && e.EndAt > request.From);
|
||||||
|
|
||||||
|
if (request.MemberIds is { Count: > 0 } members)
|
||||||
|
{
|
||||||
|
var memberSet = members.ToHashSet();
|
||||||
|
// OR across two paths: the event surfaces on the per-member view
|
||||||
|
// either because its source calendar is bound to the member
|
||||||
|
// (RF-CAL-007) or because it has been explicitly tagged with
|
||||||
|
// them at the event level (RF-CAL-013).
|
||||||
|
events = events.Where(e =>
|
||||||
|
(e.LinkedCalendar!.FamilyMemberId.HasValue
|
||||||
|
&& memberSet.Contains(e.LinkedCalendar.FamilyMemberId.Value))
|
||||||
|
|| e.RelatedMembers.Any(m => memberSet.Contains(m.Id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = await events
|
||||||
|
.OrderBy(e => e.StartAt)
|
||||||
|
.ThenBy(e => e.Title)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
IReadOnlyList<CalendarEventDto> dto = [.. rows.Select(CalendarEventDto.From)];
|
||||||
|
return Result<IReadOnlyList<CalendarEventDto>>.Success(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/FamilyNido.Api/Features/Calendar/ListLinkedAccounts.cs
Normal file
49
src/FamilyNido.Api/Features/Calendar/ListLinkedAccounts.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>Slice: list every Google account linked under the current family.</summary>
|
||||||
|
public static class ListLinkedAccounts
|
||||||
|
{
|
||||||
|
/// <summary>Empty query.</summary>
|
||||||
|
public sealed record Query() : IRequest<Result<IReadOnlyList<GoogleAccountDto>>>;
|
||||||
|
|
||||||
|
/// <summary>Handler — returns all family accounts (every adult sees the family-wide picture).</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Query, Result<IReadOnlyList<GoogleAccountDto>>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<IReadOnlyList<GoogleAccountDto>>> HandleAsync(Query request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var accounts = await _db.GoogleAccounts
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.FamilyId == current.Family.Id)
|
||||||
|
.Include(a => a.Calendars)
|
||||||
|
.OrderBy(a => a.Email)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
IReadOnlyList<GoogleAccountDto> dto = [.. accounts.Select(GoogleAccountDto.From)];
|
||||||
|
return Result<IReadOnlyList<GoogleAccountDto>>.Success(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: replace the set of family members tagged on a calendar event. The
|
||||||
|
/// API is set-based (not add/remove) because the editor in the UI shows the
|
||||||
|
/// full picker and posts the selection in one call — easier to reason about
|
||||||
|
/// than incremental diffs (RF-CAL-013).
|
||||||
|
/// </summary>
|
||||||
|
public static class SetCalendarEventMembers
|
||||||
|
{
|
||||||
|
/// <summary>Command.</summary>
|
||||||
|
/// <param name="EventId">Local event id (FamilyNido <c>CalendarEvent.Id</c>).</param>
|
||||||
|
/// <param name="MemberIds">New set of related members. Empty clears the relation.</param>
|
||||||
|
public sealed record Command(Guid EventId, IReadOnlyList<Guid> MemberIds)
|
||||||
|
: IRequest<Result<CalendarEventDto>>;
|
||||||
|
|
||||||
|
/// <summary>Validator.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.EventId).NotEmpty();
|
||||||
|
RuleFor(x => x.MemberIds).NotNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Handler.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<CalendarEventDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<CalendarEventDto>> HandleAsync(Command request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(ct);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ev = await _db.CalendarEvents
|
||||||
|
.Include(e => e.LinkedCalendar)
|
||||||
|
.Include(e => e.RelatedMembers)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
e => e.Id == request.EventId && e.FamilyId == current.Family.Id,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (ev is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("calendar_event.not_found", "Event not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var newIds = request.MemberIds.ToHashSet();
|
||||||
|
var newMembers = newIds.Count == 0
|
||||||
|
? []
|
||||||
|
: await _db.FamilyMembers
|
||||||
|
.Where(m => m.FamilyId == current.Family.Id && newIds.Contains(m.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (newMembers.Count != newIds.Count)
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation(
|
||||||
|
"calendar_event.unknown_member",
|
||||||
|
"One or more referenced members are not part of this family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the set in-memory; EF persists the join changes on save.
|
||||||
|
ev.RelatedMembers.Clear();
|
||||||
|
foreach (var m in newMembers)
|
||||||
|
{
|
||||||
|
ev.RelatedMembers.Add(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return CalendarEventDto.From(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/FamilyNido.Api/Features/Calendar/StartGoogleLink.cs
Normal file
50
src/FamilyNido.Api/Features/Calendar/StartGoogleLink.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: kick off the Google OAuth flow for the current user. Returns the URL the
|
||||||
|
/// frontend should redirect to plus the encrypted state payload that has to be set
|
||||||
|
/// in a short-lived cookie alongside the redirect.
|
||||||
|
/// </summary>
|
||||||
|
public static class StartGoogleLink
|
||||||
|
{
|
||||||
|
/// <summary>Empty command — the caller is identified via <see cref="ICurrentUserContext"/>.</summary>
|
||||||
|
public sealed record Command() : IRequest<Result<Response>>;
|
||||||
|
|
||||||
|
/// <summary>Response returned to the frontend.</summary>
|
||||||
|
/// <param name="AuthUrl">Full Google authorization URL the user has to follow.</param>
|
||||||
|
/// <param name="EncryptedState">Encrypted state to set in the OAuth state cookie.</param>
|
||||||
|
/// <param name="ExpiresAt">UTC instant the state expires (cookie max-age cap).</param>
|
||||||
|
public sealed record Response(string AuthUrl, string EncryptedState, DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
/// <summary>Handler — composes the auth URL via <see cref="GoogleOAuthService"/>.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<Response>>
|
||||||
|
{
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
private readonly GoogleOAuthService _oauth;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ICurrentUserContext userContext, GoogleOAuthService oauth)
|
||||||
|
{
|
||||||
|
_userContext = userContext;
|
||||||
|
_oauth = oauth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<Response>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var (authUrl, encryptedState, expiresAt) = _oauth.BuildAuthorizationRequest(current.User.Id);
|
||||||
|
return new Response(authUrl, encryptedState, expiresAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/FamilyNido.Api/Features/Calendar/TriggerManualSync.cs
Normal file
67
src/FamilyNido.Api/Features/Calendar/TriggerManualSync.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>Slice: trigger an inline sync of a single Google account on demand (RF-CAL-005).</summary>
|
||||||
|
public static class TriggerManualSync
|
||||||
|
{
|
||||||
|
/// <summary>Command — identifies the account to sync.</summary>
|
||||||
|
/// <param name="GoogleAccountId">Id of the account to sync.</param>
|
||||||
|
public sealed record Command(Guid GoogleAccountId) : IRequest<Result<GoogleAccountDto>>;
|
||||||
|
|
||||||
|
/// <summary>Handler — runs the synchronizer, then returns the updated DTO.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<GoogleAccountDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
private readonly CalendarSynchronizer _synchronizer;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ICurrentUserContext userContext,
|
||||||
|
CalendarSynchronizer synchronizer)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
_synchronizer = synchronizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<GoogleAccountDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var account = await _db.GoogleAccounts
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == request.GoogleAccountId && a.FamilyId == current.Family.Id,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (account is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound(
|
||||||
|
"calendar.account_not_found",
|
||||||
|
"La cuenta vinculada no existe o no pertenece a tu familia.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _synchronizer.SyncAccountAsync(account.Id, cancellationToken);
|
||||||
|
|
||||||
|
// Re-load with calendars attached to surface fresh LastSyncedAt values.
|
||||||
|
var refreshed = await _db.GoogleAccounts
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(a => a.Calendars)
|
||||||
|
.FirstAsync(a => a.Id == account.Id, cancellationToken);
|
||||||
|
|
||||||
|
return GoogleAccountDto.From(refreshed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/FamilyNido.Api/Features/Calendar/UnlinkGoogleAccount.cs
Normal file
72
src/FamilyNido.Api/Features/Calendar/UnlinkGoogleAccount.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: drop a linked Google account along with its calendars and cached events.
|
||||||
|
/// The owning user (or an admin) is the only one allowed to unlink — otherwise any
|
||||||
|
/// adult could wipe another user's integration.
|
||||||
|
/// </summary>
|
||||||
|
public static class UnlinkGoogleAccount
|
||||||
|
{
|
||||||
|
/// <summary>Command — identifies the account to unlink.</summary>
|
||||||
|
/// <param name="GoogleAccountId">Id of the account row.</param>
|
||||||
|
public sealed record Command(Guid GoogleAccountId) : IRequest<Result<Unit>>;
|
||||||
|
|
||||||
|
/// <summary>Carries no value; signals "completed" to the endpoint.</summary>
|
||||||
|
public sealed record Unit;
|
||||||
|
|
||||||
|
/// <summary>Handler — deletes the row; cascade removes calendars and events.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var account = await _db.GoogleAccounts
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == request.GoogleAccountId && a.FamilyId == current.Family.Id,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (account is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound(
|
||||||
|
"calendar.account_not_found",
|
||||||
|
"La cuenta vinculada no existe o no pertenece a tu familia.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var isOwner = account.UserId == current.User.Id;
|
||||||
|
var isAdmin = current.User.Role == Domain.Families.FamilyRole.Admin;
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden(
|
||||||
|
"calendar.account_forbidden",
|
||||||
|
"Solo el dueño de la cuenta o un admin puede desvincularla.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.GoogleAccounts.Remove(account);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return new Unit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/FamilyNido.Api/Features/Calendar/UpdateLinkedCalendar.cs
Normal file
104
src/FamilyNido.Api/Features/Calendar/UpdateLinkedCalendar.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: toggle the import flag and/or assign a family member to a calendar. Toggling
|
||||||
|
/// import off purges the cached events immediately so the UI does not keep stale rows.
|
||||||
|
/// </summary>
|
||||||
|
public static class UpdateLinkedCalendar
|
||||||
|
{
|
||||||
|
/// <summary>Command — partial update of a linked calendar.</summary>
|
||||||
|
/// <param name="LinkedCalendarId">Id of the linked calendar to update.</param>
|
||||||
|
/// <param name="IsImported">New value for the import flag.</param>
|
||||||
|
/// <param name="FamilyMemberId">Optional family member to associate (null clears the assignment).</param>
|
||||||
|
public sealed record Command(
|
||||||
|
Guid LinkedCalendarId,
|
||||||
|
bool IsImported,
|
||||||
|
Guid? FamilyMemberId) : IRequest<Result<LinkedCalendarDto>>;
|
||||||
|
|
||||||
|
/// <summary>Input validation.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.LinkedCalendarId).NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Handler — applies the patch and saves.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<LinkedCalendarDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<LinkedCalendarDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var calendar = await _db.LinkedCalendars
|
||||||
|
.Include(c => c.GoogleAccount)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
c => c.Id == request.LinkedCalendarId &&
|
||||||
|
c.GoogleAccount!.FamilyId == current.Family.Id,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (calendar is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound(
|
||||||
|
"calendar.linked_calendar_not_found",
|
||||||
|
"El calendario indicado no existe o no pertenece a tu familia.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.FamilyMemberId is { } memberId)
|
||||||
|
{
|
||||||
|
var memberExists = await _db.FamilyMembers
|
||||||
|
.AnyAsync(m => m.Id == memberId && m.FamilyId == current.Family.Id, cancellationToken);
|
||||||
|
if (!memberExists)
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation(
|
||||||
|
"calendar.member_not_found",
|
||||||
|
"El miembro familiar indicado no existe.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var willTurnOff = calendar.IsImported && !request.IsImported;
|
||||||
|
|
||||||
|
calendar.IsImported = request.IsImported;
|
||||||
|
calendar.FamilyMemberId = request.FamilyMemberId;
|
||||||
|
|
||||||
|
// Toggling import off clears cached events and the sync token so a future
|
||||||
|
// toggle-on triggers a fresh full sync rather than a delta from a stale cursor.
|
||||||
|
if (willTurnOff)
|
||||||
|
{
|
||||||
|
await _db.CalendarEvents
|
||||||
|
.Where(e => e.LinkedCalendarId == calendar.Id)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
calendar.SyncToken = null;
|
||||||
|
calendar.LastSyncedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return LinkedCalendarDto.From(calendar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/FamilyNido.Api/Features/Dashboard/DashboardEndpoints.cs
Normal file
41
src/FamilyNido.Api/Features/Dashboard/DashboardEndpoints.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Http;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>Endpoints exposing the dashboard widget layout.</summary>
|
||||||
|
public static class DashboardEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Registers <c>GET/PUT /api/dashboard/preferences</c>.</summary>
|
||||||
|
public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/dashboard").WithTags("Dashboard");
|
||||||
|
|
||||||
|
group.MapGet("/preferences", GetPreferencesAsync)
|
||||||
|
.RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
group.MapPut("/preferences", UpdatePreferencesAsync)
|
||||||
|
.RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetPreferencesAsync(IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new GetMyDashboardPreferences.Query(), ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdatePreferencesAsync(
|
||||||
|
UpdateMyDashboardPreferences.Command command,
|
||||||
|
IValidator<UpdateMyDashboardPreferences.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace FamilyNido.Api.Features.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>One widget entry in the user's dashboard layout.</summary>
|
||||||
|
/// <param name="Id">Stable widget identifier (see <see cref="DashboardWidgets"/>).</param>
|
||||||
|
/// <param name="Visible">True when the widget is rendered on the dashboard.</param>
|
||||||
|
public sealed record DashboardWidgetDto(string Id, bool Visible);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire shape returned by <c>GET /api/dashboard/preferences</c>: the ordered
|
||||||
|
/// list of widget entries. Always includes every known widget so the frontend
|
||||||
|
/// renders a complete settings screen even when the user has never touched it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DashboardPreferencesDto(IReadOnlyList<DashboardWidgetDto> Widgets);
|
||||||
51
src/FamilyNido.Api/Features/Dashboard/DashboardWidgets.cs
Normal file
51
src/FamilyNido.Api/Features/Dashboard/DashboardWidgets.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
namespace FamilyNido.Api.Features.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Catalogue of widget identifiers known to the dashboard. Anything outside
|
||||||
|
/// this list is rejected at the application layer when the user submits a
|
||||||
|
/// new layout — the frontend mirrors the IDs verbatim.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Adding a widget is a three-step dance: extend this list (and document the
|
||||||
|
/// label), include it in <see cref="DefaultOrder"/>, and render it in the
|
||||||
|
/// dashboard component. New widgets default to visible at the bottom for
|
||||||
|
/// existing users.
|
||||||
|
/// </remarks>
|
||||||
|
public static class DashboardWidgets
|
||||||
|
{
|
||||||
|
/// <summary>Open-Meteo current-weather card.</summary>
|
||||||
|
public const string Weather = "weather";
|
||||||
|
|
||||||
|
/// <summary>Today's bus / extras / drop-off + pick-up summary.</summary>
|
||||||
|
public const string School = "school";
|
||||||
|
|
||||||
|
/// <summary>Pending household tasks for today.</summary>
|
||||||
|
public const string Tasks = "tasks";
|
||||||
|
|
||||||
|
/// <summary>Top of the upcoming Google Calendar events list.</summary>
|
||||||
|
public const string Calendar = "calendar";
|
||||||
|
|
||||||
|
/// <summary>Today's and tomorrow's planned meals.</summary>
|
||||||
|
public const string Meals = "meals";
|
||||||
|
|
||||||
|
/// <summary>Pinned wall messages.</summary>
|
||||||
|
public const string Wall = "wall";
|
||||||
|
|
||||||
|
/// <summary>Birthdays in the next 30 days.</summary>
|
||||||
|
public const string Birthdays = "birthdays";
|
||||||
|
|
||||||
|
/// <summary>Today's agenda — who is away from home and what for.</summary>
|
||||||
|
public const string Agenda = "agenda";
|
||||||
|
|
||||||
|
/// <summary>Family scoreboard — points earned this week from completed tasks.</summary>
|
||||||
|
public const string Scores = "scores";
|
||||||
|
|
||||||
|
/// <summary>Default order applied when a user has no row yet.</summary>
|
||||||
|
public static IReadOnlyList<string> DefaultOrder => new[]
|
||||||
|
{
|
||||||
|
Weather, School, Agenda, Tasks, Calendar, Meals, Wall, Scores, Birthdays,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="id"/> is a known widget id.</summary>
|
||||||
|
public static bool IsKnown(string id) => DefaultOrder.Contains(id);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice for <c>GET /api/dashboard/preferences</c>. Reconciles the persisted
|
||||||
|
/// JSON with the catalogue of known widgets so:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Widgets the user explicitly hid stay hidden.</item>
|
||||||
|
/// <item>Widgets removed from the catalogue silently disappear.</item>
|
||||||
|
/// <item>New widgets added to the catalogue land at the end, visible by default.</item>
|
||||||
|
/// </list>
|
||||||
|
/// The result is the layout the frontend renders straight away.
|
||||||
|
/// </summary>
|
||||||
|
public static class GetMyDashboardPreferences
|
||||||
|
{
|
||||||
|
/// <summary>Query carries no payload — the caller is the implicit subject.</summary>
|
||||||
|
public sealed record Query : IRequest<Result<DashboardPreferencesDto>>;
|
||||||
|
|
||||||
|
/// <summary>Reads + reconciles the row.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Query, Result<DashboardPreferencesDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<DashboardPreferencesDto>> HandleAsync(Query request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _userContext.GetUserAsync(cancellationToken);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "Not signed in.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored = await _db.UserDashboardPreferences
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => p.UserId == user.Id)
|
||||||
|
.Select(p => p.WidgetsJson)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new DashboardPreferencesDto(Reconcile(stored));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the effective widget list: persisted entries are kept in their
|
||||||
|
/// stored order (filtering out IDs no longer in the catalogue), and any
|
||||||
|
/// new catalogue entry is appended at the end with <c>Visible=true</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyList<DashboardWidgetDto> Reconcile(string? widgetsJson)
|
||||||
|
{
|
||||||
|
var stored = ParseStored(widgetsJson);
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var result = new List<DashboardWidgetDto>();
|
||||||
|
|
||||||
|
foreach (var entry in stored)
|
||||||
|
{
|
||||||
|
if (!DashboardWidgets.IsKnown(entry.Id) || !seen.Add(entry.Id)) continue;
|
||||||
|
result.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var id in DashboardWidgets.DefaultOrder)
|
||||||
|
{
|
||||||
|
if (seen.Add(id))
|
||||||
|
{
|
||||||
|
result.Add(new DashboardWidgetDto(id, Visible: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<DashboardWidgetDto> ParseStored(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = JsonSerializer.Deserialize<List<DashboardWidgetDto>>(
|
||||||
|
json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
return parsed ?? [];
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Corrupted column — treat as defaults rather than failing the whole API.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Notifications;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice for <c>PUT /api/dashboard/preferences</c>. Replaces the user's widget
|
||||||
|
/// layout with the supplied list. The persisted JSON is then re-reconciled on
|
||||||
|
/// read, so unknown ids the caller may slip in are simply ignored next time.
|
||||||
|
/// </summary>
|
||||||
|
public static class UpdateMyDashboardPreferences
|
||||||
|
{
|
||||||
|
/// <summary>Command carrying the full ordered widget list.</summary>
|
||||||
|
public sealed record Command(IReadOnlyList<DashboardWidgetDto> Widgets)
|
||||||
|
: IRequest<Result<DashboardPreferencesDto>>;
|
||||||
|
|
||||||
|
/// <summary>Validation: every id must be from the catalogue and unique.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator with all rules registered.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Widgets).NotNull();
|
||||||
|
RuleForEach(x => x.Widgets)
|
||||||
|
.Must(w => DashboardWidgets.IsKnown(w.Id))
|
||||||
|
.WithMessage("Unknown widget id.");
|
||||||
|
RuleFor(x => x.Widgets)
|
||||||
|
.Must(list => list.Select(w => w.Id).Distinct().Count() == list.Count)
|
||||||
|
.WithMessage("Duplicate widget id.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Performs the upsert.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<DashboardPreferencesDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<DashboardPreferencesDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _userContext.GetUserAsync(cancellationToken);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.unauthenticated", "Not signed in.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialise the requested layout — Reconcile() will trim unknowns
|
||||||
|
// on the next read, so what we persist is exactly what we accept.
|
||||||
|
var json = JsonSerializer.Serialize(request.Widgets);
|
||||||
|
|
||||||
|
var prefs = await _db.UserDashboardPreferences
|
||||||
|
.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
|
||||||
|
if (prefs is null)
|
||||||
|
{
|
||||||
|
prefs = new UserDashboardPreferences { UserId = user.Id, WidgetsJson = json };
|
||||||
|
_db.UserDashboardPreferences.Add(prefs);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
prefs.WidgetsJson = json;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new DashboardPreferencesDto(GetMyDashboardPreferences.Handler.Reconcile(json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/FamilyNido.Api/Features/Families/FamilyDto.cs
Normal file
18
src/FamilyNido.Api/Features/Families/FamilyDto.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
namespace FamilyNido.Api.Features.Families;
|
||||||
|
|
||||||
|
/// <summary>Wire shape returned by the family settings endpoint.</summary>
|
||||||
|
/// <param name="Id">Family id.</param>
|
||||||
|
/// <param name="Name">Display name shown in the header.</param>
|
||||||
|
/// <param name="TimeZone">IANA timezone (e.g. "Europe/Madrid").</param>
|
||||||
|
/// <param name="Locale">BCP-47 locale used for default formatting.</param>
|
||||||
|
/// <param name="Latitude">Geographic latitude in decimal degrees, or null when not configured.</param>
|
||||||
|
/// <param name="Longitude">Geographic longitude paired with latitude.</param>
|
||||||
|
/// <param name="LocationLabel">Human-readable location label (city/town/village).</param>
|
||||||
|
public sealed record FamilyDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string TimeZone,
|
||||||
|
string Locale,
|
||||||
|
double? Latitude,
|
||||||
|
double? Longitude,
|
||||||
|
string? LocationLabel);
|
||||||
43
src/FamilyNido.Api/Features/Families/FamilyEndpoints.cs
Normal file
43
src/FamilyNido.Api/Features/Families/FamilyEndpoints.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Http;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Families;
|
||||||
|
|
||||||
|
/// <summary>Endpoints exposing the family profile (read for everyone, mutate for admins).</summary>
|
||||||
|
public static class FamilyEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Registers <c>GET /api/family</c> and <c>PUT /api/family/location</c>.</summary>
|
||||||
|
public static IEndpointRouteBuilder MapFamilyEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/family").WithTags("Family");
|
||||||
|
|
||||||
|
group.MapGet("/", GetAsync).RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
group.MapPut("/location", UpdateLocationAsync).RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetAsync(IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new GetMyFamily.Query(), ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateLocationAsync(
|
||||||
|
UpdateFamilyLocation.Command command,
|
||||||
|
IValidator<UpdateFamilyLocation.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/FamilyNido.Api/Features/Families/GetMyFamily.cs
Normal file
38
src/FamilyNido.Api/Features/Families/GetMyFamily.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Families;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice for <c>GET /api/family</c>. Returns the caller's family profile —
|
||||||
|
/// any authenticated member of the family can read it.
|
||||||
|
/// </summary>
|
||||||
|
public static class GetMyFamily
|
||||||
|
{
|
||||||
|
/// <summary>Query carries no payload; the caller resolves the family.</summary>
|
||||||
|
public sealed record Query : IRequest<Result<FamilyDto>>;
|
||||||
|
|
||||||
|
/// <summary>Resolves the family through <see cref="ICurrentUserContext"/>.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Query, Result<FamilyDto>>
|
||||||
|
{
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ICurrentUserContext userContext) => _userContext = userContext;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FamilyDto>> HandleAsync(Query request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var f = current.Family;
|
||||||
|
return new FamilyDto(f.Id, f.Name, f.TimeZone, f.Locale, f.Latitude, f.Longitude, f.LocationLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/FamilyNido.Api/Features/Families/UpdateFamilyLocation.cs
Normal file
95
src/FamilyNido.Api/Features/Families/UpdateFamilyLocation.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Families;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice for <c>PUT /api/family/location</c>. Lets a family admin set or
|
||||||
|
/// clear the family's geographic location. Drives the dashboard weather
|
||||||
|
/// widget — without coordinates, the widget hides itself.
|
||||||
|
/// </summary>
|
||||||
|
public static class UpdateFamilyLocation
|
||||||
|
{
|
||||||
|
/// <summary>Command carrying the new location, or all nulls to clear it.</summary>
|
||||||
|
/// <param name="Latitude">Decimal degrees in [-90, 90], or null to clear.</param>
|
||||||
|
/// <param name="Longitude">Decimal degrees in [-180, 180], or null to clear.</param>
|
||||||
|
/// <param name="LocationLabel">Optional human-readable label (city/town/village).</param>
|
||||||
|
public sealed record Command(
|
||||||
|
double? Latitude,
|
||||||
|
double? Longitude,
|
||||||
|
string? LocationLabel) : IRequest<Result<FamilyDto>>;
|
||||||
|
|
||||||
|
/// <summary>Validation: lat/lon must be both null or both within range.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator with all rules registered.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Latitude)
|
||||||
|
.InclusiveBetween(-90, 90)
|
||||||
|
.When(x => x.Latitude is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Longitude)
|
||||||
|
.InclusiveBetween(-180, 180)
|
||||||
|
.When(x => x.Longitude is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(c => (c.Latitude is null) == (c.Longitude is null))
|
||||||
|
.WithMessage("Latitude and Longitude must be set or cleared together.");
|
||||||
|
|
||||||
|
RuleFor(x => x.LocationLabel).MaximumLength(120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Persists the new location on the family row.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<FamilyDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FamilyDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.User.Role != FamilyRole.Admin)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("family.location.admin_only", "Only family admins can change the location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var family = await _db.Families.FirstAsync(f => f.Id == current.Family.Id, cancellationToken);
|
||||||
|
|
||||||
|
family.Latitude = request.Latitude;
|
||||||
|
family.Longitude = request.Longitude;
|
||||||
|
family.LocationLabel = string.IsNullOrWhiteSpace(request.LocationLabel) ? null : request.LocationLabel.Trim();
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new FamilyDto(
|
||||||
|
family.Id,
|
||||||
|
family.Name,
|
||||||
|
family.TimeZone,
|
||||||
|
family.Locale,
|
||||||
|
family.Latitude,
|
||||||
|
family.Longitude,
|
||||||
|
family.LocationLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>Slice: re-activate a previously archived member (RF-USR-004).</summary>
|
||||||
|
public static class ActivateFamilyMember
|
||||||
|
{
|
||||||
|
/// <summary>Command carrying the target id.</summary>
|
||||||
|
public sealed record Command(Guid MemberId) : IRequest<Result<FamilyMemberDto>>;
|
||||||
|
|
||||||
|
/// <summary>Handler — flips <c>IsActive</c> to true.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await _db.FamilyMembers
|
||||||
|
.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
m => m.Id == request.MemberId && m.FamilyId == current.Family.Id,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound(
|
||||||
|
"family_member.not_found",
|
||||||
|
$"Member {request.MemberId} not found in family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
member.IsActive = true;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return FamilyMemberDto.From(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/FamilyNido.Api/Features/FamilyMembers/CreateFamilyMember.cs
Normal file
102
src/FamilyNido.Api/Features/FamilyMembers/CreateFamilyMember.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>Slice: create a new <see cref="FamilyMember"/> under the caller's family (RF-USR-002).</summary>
|
||||||
|
public static partial class CreateFamilyMember
|
||||||
|
{
|
||||||
|
[GeneratedRegex("^#[0-9a-fA-F]{6}$", RegexOptions.Compiled)]
|
||||||
|
private static partial Regex HexColorRegex();
|
||||||
|
|
||||||
|
/// <summary>Command carrying the input payload.</summary>
|
||||||
|
/// <param name="DisplayName">Name shown in the UI. 1-120 chars.</param>
|
||||||
|
/// <param name="MemberType">Kind of member.</param>
|
||||||
|
/// <param name="ColorHex">Hex color (#RRGGBB) to use consistently across the app.</param>
|
||||||
|
/// <param name="BirthDate">Optional date of birth.</param>
|
||||||
|
/// <param name="ContactEmail">Optional informational contact email.</param>
|
||||||
|
public sealed record Command(
|
||||||
|
string DisplayName,
|
||||||
|
MemberType MemberType,
|
||||||
|
string ColorHex,
|
||||||
|
DateOnly? BirthDate,
|
||||||
|
string? ContactEmail) : IRequest<Result<FamilyMemberDto>>;
|
||||||
|
|
||||||
|
/// <summary>Input validation.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator with all rules registered.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.DisplayName)
|
||||||
|
.NotEmpty()
|
||||||
|
.MaximumLength(120);
|
||||||
|
|
||||||
|
RuleFor(x => x.ColorHex)
|
||||||
|
.NotEmpty()
|
||||||
|
.Matches(HexColorRegex())
|
||||||
|
.WithMessage("ColorHex must be in #RRGGBB format.");
|
||||||
|
|
||||||
|
RuleFor(x => x.ContactEmail)
|
||||||
|
.EmailAddress()
|
||||||
|
.MaximumLength(254)
|
||||||
|
.When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
|
||||||
|
|
||||||
|
RuleFor(x => x.BirthDate)
|
||||||
|
.Must(d => d is null || d.Value <= DateOnly.FromDateTime(DateTime.UtcNow))
|
||||||
|
.WithMessage("BirthDate cannot be in the future.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Handler — persists a new family member row.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var familyExists = await _db.Families.AnyAsync(f => f.Id == current.Family.Id, cancellationToken);
|
||||||
|
if (!familyExists)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("family.not_found", $"Family {current.Family.Id} does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = new FamilyMember
|
||||||
|
{
|
||||||
|
FamilyId = current.Family.Id,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
MemberType = request.MemberType,
|
||||||
|
ColorHex = request.ColorHex,
|
||||||
|
BirthDate = request.BirthDate,
|
||||||
|
ContactEmail = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail,
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.FamilyMembers.Add(member);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return FamilyMemberDto.From(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>Slice: soft-deactivate a member while preserving history (RF-USR-004).</summary>
|
||||||
|
public static class DeactivateFamilyMember
|
||||||
|
{
|
||||||
|
/// <summary>Command carrying the target id.</summary>
|
||||||
|
public sealed record Command(Guid MemberId) : IRequest<Result<FamilyMemberDto>>;
|
||||||
|
|
||||||
|
/// <summary>Handler — enforces the at-least-one-admin invariant.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await _db.FamilyMembers
|
||||||
|
.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
m => m.Id == request.MemberId && m.FamilyId == current.Family.Id,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound(
|
||||||
|
"family_member.not_found",
|
||||||
|
$"Member {request.MemberId} not found in family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await WouldLeaveZeroAdminsAsync(_db, member, cancellationToken))
|
||||||
|
{
|
||||||
|
return ApplicationError.Conflict(
|
||||||
|
"family.must_keep_admin",
|
||||||
|
"Cannot deactivate the last admin of the family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
member.IsActive = false;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return FamilyMemberDto.From(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="member"/> is an active admin AND there are no
|
||||||
|
/// other active admins in the family. Shared with <see cref="DeleteFamilyMember"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<bool> WouldLeaveZeroAdminsAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
FamilyMember member,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (member.User is null || member.User.Role != FamilyRole.Admin)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var otherActiveAdmins = await db.FamilyMembers
|
||||||
|
.CountAsync(m =>
|
||||||
|
m.FamilyId == member.FamilyId
|
||||||
|
&& m.Id != member.Id
|
||||||
|
&& m.IsActive
|
||||||
|
&& m.User != null
|
||||||
|
&& m.User.Role == FamilyRole.Admin,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
return otherActiveAdmins == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>Slice: hard-delete a member, preserving the at-least-one-admin invariant (RF-USR-004).</summary>
|
||||||
|
public static class DeleteFamilyMember
|
||||||
|
{
|
||||||
|
/// <summary>Command carrying the target id.</summary>
|
||||||
|
public sealed record Command(Guid MemberId) : IRequest<Result<Unit>>;
|
||||||
|
|
||||||
|
/// <summary>Handler — refuses if the target is the last admin.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await _db.FamilyMembers
|
||||||
|
.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
m => m.Id == request.MemberId && m.FamilyId == current.Family.Id,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound(
|
||||||
|
"family_member.not_found",
|
||||||
|
$"Member {request.MemberId} not found in family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await DeactivateFamilyMember.WouldLeaveZeroAdminsAsync(_db, member, cancellationToken))
|
||||||
|
{
|
||||||
|
return ApplicationError.Conflict(
|
||||||
|
"family.must_keep_admin",
|
||||||
|
"Cannot delete the last admin of the family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.FamilyMembers.Remove(member);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/FamilyNido.Api/Features/FamilyMembers/FamilyMemberDto.cs
Normal file
47
src/FamilyNido.Api/Features/FamilyMembers/FamilyMemberDto.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
using FamilyNido.Api.Features.Invitations;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using FamilyNido.Domain.Identity;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-model projection of a <see cref="FamilyMember"/> returned by the API.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Id">Stable member identifier.</param>
|
||||||
|
/// <param name="DisplayName">Name shown across the UI.</param>
|
||||||
|
/// <param name="MemberType">Kind of member (adult/child/other).</param>
|
||||||
|
/// <param name="ColorHex">Consistent color for this member in calendar and avatars.</param>
|
||||||
|
/// <param name="BirthDate">Date of birth if known.</param>
|
||||||
|
/// <param name="ContactEmail">Informational contact email, not the OIDC email.</param>
|
||||||
|
/// <param name="PhotoPath">Relative path to the avatar file, if any.</param>
|
||||||
|
/// <param name="IsActive">Whether the member is active or archived.</param>
|
||||||
|
/// <param name="HasAccount">True when a user account is linked.</param>
|
||||||
|
/// <param name="Role">Authorization role if the member has an account, else null.</param>
|
||||||
|
/// <param name="PendingInvitation">Pending (non-consumed, non-revoked, non-expired) invitation, if any.</param>
|
||||||
|
public sealed record FamilyMemberDto(
|
||||||
|
Guid Id,
|
||||||
|
string DisplayName,
|
||||||
|
MemberType MemberType,
|
||||||
|
string ColorHex,
|
||||||
|
DateOnly? BirthDate,
|
||||||
|
string? ContactEmail,
|
||||||
|
string? PhotoPath,
|
||||||
|
bool IsActive,
|
||||||
|
bool HasAccount,
|
||||||
|
FamilyRole? Role,
|
||||||
|
PendingInvitationDto? PendingInvitation)
|
||||||
|
{
|
||||||
|
/// <summary>Project a domain entity to the DTO shape used by the API.</summary>
|
||||||
|
public static FamilyMemberDto From(FamilyMember m, Invitation? pending = null) => new(
|
||||||
|
Id: m.Id,
|
||||||
|
DisplayName: m.DisplayName,
|
||||||
|
MemberType: m.MemberType,
|
||||||
|
ColorHex: m.ColorHex,
|
||||||
|
BirthDate: m.BirthDate,
|
||||||
|
ContactEmail: m.ContactEmail,
|
||||||
|
PhotoPath: m.PhotoPath,
|
||||||
|
IsActive: m.IsActive,
|
||||||
|
HasAccount: m.UserId is not null,
|
||||||
|
Role: m.User?.Role,
|
||||||
|
PendingInvitation: pending is null ? null : new PendingInvitationDto(pending.Id, pending.Email, pending.ExpiresAt));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using FamilyNido.Api.Shared.Http;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>REST endpoints for managing family members (RF-USR-*).</summary>
|
||||||
|
public static class FamilyMemberEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Registers <c>/api/family-members</c> endpoints on the given route group.</summary>
|
||||||
|
public static IEndpointRouteBuilder MapFamilyMemberEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/family-members")
|
||||||
|
.WithTags("FamilyMembers")
|
||||||
|
.RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
|
||||||
|
group.MapGet("/", ListAsync);
|
||||||
|
group.MapGet("/{id:guid}", GetAsync);
|
||||||
|
group.MapPost("/", CreateAsync).RequireAuthorization(Policies.Admin);
|
||||||
|
group.MapPut("/{id:guid}", UpdateAsync).RequireAuthorization(Policies.Admin);
|
||||||
|
group.MapPatch("/{id:guid}/deactivate", DeactivateAsync).RequireAuthorization(Policies.Admin);
|
||||||
|
group.MapPatch("/{id:guid}/activate", ActivateAsync).RequireAuthorization(Policies.Admin);
|
||||||
|
group.MapDelete("/{id:guid}", DeleteAsync).RequireAuthorization(Policies.Admin);
|
||||||
|
|
||||||
|
// Profile photo: any authenticated user can read the bytes; the
|
||||||
|
// upload/delete handlers run their own admin-OR-self check inside.
|
||||||
|
group.MapGet("/{id:guid}/photo", DownloadPhotoAsync);
|
||||||
|
group.MapPost("/{id:guid}/photo", UploadPhotoAsync).DisableAntiforgery();
|
||||||
|
group.MapDelete("/{id:guid}/photo", RemovePhotoAsync);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListAsync(
|
||||||
|
bool? includeInactive,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new ListFamilyMembers.Query(includeInactive ?? false), ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetAsync(
|
||||||
|
Guid id,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new GetFamilyMember.Query(id), ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateAsync(
|
||||||
|
CreateFamilyMember.Command command,
|
||||||
|
IValidator<CreateFamilyMember.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Created($"/api/family-members/{result.Value.Id}", result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateAsync(
|
||||||
|
Guid id,
|
||||||
|
UpdateFamilyMemberBody body,
|
||||||
|
IValidator<UpdateFamilyMember.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var command = new UpdateFamilyMember.Command(id, body.DisplayName, body.ColorHex, body.BirthDate, body.ContactEmail);
|
||||||
|
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null)
|
||||||
|
{
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ActivateAsync(Guid id, IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new ActivateFamilyMember.Command(id), ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeactivateAsync(Guid id, IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new DeactivateFamilyMember.Command(id), ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteAsync(Guid id, IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new DeleteFamilyMember.Command(id), ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.NoContent()
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UploadPhotoAsync(
|
||||||
|
Guid id,
|
||||||
|
HttpRequest request,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!request.HasFormContentType)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { code = "photo.missing_multipart", message = "Upload must be multipart/form-data." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = await request.ReadFormAsync(ct);
|
||||||
|
var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault();
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { code = "photo.missing", message = "No file provided." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
var command = new UploadMemberPhoto.Command(id, stream, file.ContentType, file.Length);
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
catch (SixLabors.ImageSharp.UnknownImageFormatException)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { code = "photo.unsupported_type", message = "File could not be decoded as a supported image." });
|
||||||
|
}
|
||||||
|
catch (SixLabors.ImageSharp.InvalidImageContentException)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { code = "photo.corrupt", message = "Image data is malformed." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RemovePhotoAsync(Guid id, IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new RemoveMemberPhoto.Command(id), ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DownloadPhotoAsync(
|
||||||
|
Guid id,
|
||||||
|
IMediator mediator,
|
||||||
|
IOptions<FilesOptions> filesOptions,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Resolve the row through the standard read slice so cross-family
|
||||||
|
// access is rejected (the slice already filters by the caller's
|
||||||
|
// family). We then stream the file from disk.
|
||||||
|
var result = await mediator.SendAsync(new GetFamilyMember.Query(id), ct);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
return result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = result.Value;
|
||||||
|
if (string.IsNullOrEmpty(member.PhotoPath))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve to an absolute path. Results.File interprets relative paths
|
||||||
|
// against the IWebHostEnvironment.WebRootFileProvider (i.e. wwwroot),
|
||||||
|
// which silently breaks when StorageRoot is configured as a relative
|
||||||
|
// path like "./data/uploads" — the default in Development.
|
||||||
|
var fullPath = Path.GetFullPath(
|
||||||
|
Path.Combine(filesOptions.Value.StorageRoot, member.PhotoPath));
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
// The pointer survived but the file is gone (manual cleanup,
|
||||||
|
// volume rotation, …). Returning 404 keeps the avatar pipeline
|
||||||
|
// falling back to initials.
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.File(fullPath, contentType: "image/jpeg", enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-level payload for PUT /api/family-members/{id}. The route parameter
|
||||||
|
/// carries the target id, so the body only carries the mutable fields.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DisplayName">Name shown in the UI. 1-120 chars.</param>
|
||||||
|
/// <param name="ColorHex">Hex color (#RRGGBB).</param>
|
||||||
|
/// <param name="BirthDate">Optional date of birth.</param>
|
||||||
|
/// <param name="ContactEmail">Optional informational contact email.</param>
|
||||||
|
public sealed record UpdateFamilyMemberBody(
|
||||||
|
string DisplayName,
|
||||||
|
string ColorHex,
|
||||||
|
DateOnly? BirthDate,
|
||||||
|
string? ContactEmail);
|
||||||
50
src/FamilyNido.Api/Features/FamilyMembers/GetFamilyMember.cs
Normal file
50
src/FamilyNido.Api/Features/FamilyMembers/GetFamilyMember.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>Fetches a single member of the caller's family by id (RF-USR-001).</summary>
|
||||||
|
public static class GetFamilyMember
|
||||||
|
{
|
||||||
|
/// <summary>Query carrying the target member id.</summary>
|
||||||
|
public sealed record Query(Guid MemberId) : IRequest<Result<FamilyMemberDto>>;
|
||||||
|
|
||||||
|
/// <summary>Handler. Resolves the calling family before querying.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Query, Result<FamilyMemberDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FamilyMemberDto>> HandleAsync(Query request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await _db.FamilyMembers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
m => m.Id == request.MemberId && m.FamilyId == current.Family.Id,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return member is null
|
||||||
|
? ApplicationError.NotFound("family_member.not_found", $"Member {request.MemberId} not found in family.")
|
||||||
|
: FamilyMemberDto.From(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>Lists members of the current user's family (RF-USR-001).</summary>
|
||||||
|
public static class ListFamilyMembers
|
||||||
|
{
|
||||||
|
/// <summary>Query carrying the optional "include archived" flag.</summary>
|
||||||
|
/// <param name="IncludeInactive">When true, archived rows are also returned.</param>
|
||||||
|
public sealed record Query(bool IncludeInactive) : IRequest<Result<IReadOnlyList<FamilyMemberDto>>>;
|
||||||
|
|
||||||
|
/// <summary>EF Core-backed handler. Requires the caller to be linked.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Query, Result<IReadOnlyList<FamilyMemberDto>>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<IReadOnlyList<FamilyMemberDto>>> HandleAsync(
|
||||||
|
Query request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
var query = _db.FamilyMembers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(m => m.User)
|
||||||
|
.Where(m => m.FamilyId == current.Family.Id);
|
||||||
|
|
||||||
|
if (!request.IncludeInactive)
|
||||||
|
{
|
||||||
|
query = query.Where(m => m.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project each member with its most-recent live invitation (if any)
|
||||||
|
// in a single SQL round-trip — avoids the N+1 we'd get if the front
|
||||||
|
// had to ask "is there a pending invitation?" per row.
|
||||||
|
var rows = await query
|
||||||
|
.OrderBy(m => m.MemberType)
|
||||||
|
.ThenBy(m => m.DisplayName)
|
||||||
|
.Select(m => new
|
||||||
|
{
|
||||||
|
Member = m,
|
||||||
|
Pending = _db.Invitations
|
||||||
|
.Where(i => i.FamilyMemberId == m.Id
|
||||||
|
&& i.ConsumedAt == null
|
||||||
|
&& i.RevokedAt == null
|
||||||
|
&& i.ExpiresAt > now)
|
||||||
|
.OrderByDescending(i => i.CreatedAt)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
IReadOnlyList<FamilyMemberDto> dto = [.. rows.Select(r => FamilyMemberDto.From(r.Member, r.Pending))];
|
||||||
|
return Result<IReadOnlyList<FamilyMemberDto>>.Success(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: clears a member's photo. Mirrors the auth rules of
|
||||||
|
/// <see cref="UploadMemberPhoto"/> (admin OR self) and removes both the row
|
||||||
|
/// pointer (PhotoPath) and the underlying file on disk so the avatar
|
||||||
|
/// pipeline falls back to initials.
|
||||||
|
/// </summary>
|
||||||
|
public static class RemoveMemberPhoto
|
||||||
|
{
|
||||||
|
/// <summary>Command.</summary>
|
||||||
|
/// <param name="MemberId">Target member id.</param>
|
||||||
|
public sealed record Command(Guid MemberId) : IRequest<Result<FamilyMemberDto>>;
|
||||||
|
|
||||||
|
/// <summary>Handler.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
private readonly FilesOptions _filesOptions;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ICurrentUserContext userContext,
|
||||||
|
IOptions<FilesOptions> filesOptions)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
_filesOptions = filesOptions.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(ct);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await _db.FamilyMembers
|
||||||
|
.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, ct);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("family_member.not_found", "Member not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAdmin = current.User.Role == FamilyRole.Admin;
|
||||||
|
var isSelf = current.Member.Id == member.Id;
|
||||||
|
if (!isAdmin && !isSelf)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden(
|
||||||
|
"family_member.not_self_or_admin",
|
||||||
|
"Only an admin or the member themself can remove this photo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(member.PhotoPath))
|
||||||
|
{
|
||||||
|
var fullPath = Path.Combine(_filesOptions.StorageRoot, member.PhotoPath);
|
||||||
|
// Best-effort delete: if the file is already gone (manual cleanup,
|
||||||
|
// volume rotated, …) we still clear the DB pointer.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
File.Delete(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Swallow — clearing the row matters more than removing the file.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
member.PhotoPath = null;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return FamilyMemberDto.From(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/FamilyNido.Api/Features/FamilyMembers/UpdateFamilyMember.cs
Normal file
102
src/FamilyNido.Api/Features/FamilyMembers/UpdateFamilyMember.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>Slice: update mutable fields of an existing member (RF-USR-003).</summary>
|
||||||
|
public static partial class UpdateFamilyMember
|
||||||
|
{
|
||||||
|
[GeneratedRegex("^#[0-9a-fA-F]{6}$", RegexOptions.Compiled)]
|
||||||
|
private static partial Regex HexColorRegex();
|
||||||
|
|
||||||
|
/// <summary>Command carrying the target id and the new values.</summary>
|
||||||
|
/// <param name="MemberId">Target member id.</param>
|
||||||
|
/// <param name="DisplayName">Name shown in the UI. 1-120 chars.</param>
|
||||||
|
/// <param name="ColorHex">Hex color (#RRGGBB).</param>
|
||||||
|
/// <param name="BirthDate">Optional date of birth.</param>
|
||||||
|
/// <param name="ContactEmail">Optional informational contact email.</param>
|
||||||
|
public sealed record Command(
|
||||||
|
Guid MemberId,
|
||||||
|
string DisplayName,
|
||||||
|
string ColorHex,
|
||||||
|
DateOnly? BirthDate,
|
||||||
|
string? ContactEmail) : IRequest<Result<FamilyMemberDto>>;
|
||||||
|
|
||||||
|
/// <summary>Input validation.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator with all rules registered.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.DisplayName)
|
||||||
|
.NotEmpty()
|
||||||
|
.MaximumLength(120);
|
||||||
|
|
||||||
|
RuleFor(x => x.ColorHex)
|
||||||
|
.NotEmpty()
|
||||||
|
.Matches(HexColorRegex())
|
||||||
|
.WithMessage("ColorHex must be in #RRGGBB format.");
|
||||||
|
|
||||||
|
RuleFor(x => x.ContactEmail)
|
||||||
|
.EmailAddress()
|
||||||
|
.MaximumLength(254)
|
||||||
|
.When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
|
||||||
|
|
||||||
|
RuleFor(x => x.BirthDate)
|
||||||
|
.Must(d => d is null || d.Value <= DateOnly.FromDateTime(DateTime.UtcNow))
|
||||||
|
.WithMessage("BirthDate cannot be in the future.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Handler — writes the mutable fields to the tracked entity.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await _db.FamilyMembers
|
||||||
|
.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
m => m.Id == request.MemberId && m.FamilyId == current.Family.Id,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound(
|
||||||
|
"family_member.not_found",
|
||||||
|
$"Member {request.MemberId} not found in family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
member.DisplayName = request.DisplayName;
|
||||||
|
member.ColorHex = request.ColorHex;
|
||||||
|
member.BirthDate = request.BirthDate;
|
||||||
|
member.ContactEmail = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return FamilyMemberDto.From(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/FamilyNido.Api/Features/FamilyMembers/UploadMemberPhoto.cs
Normal file
139
src/FamilyNido.Api/Features/FamilyMembers/UploadMemberPhoto.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Families;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.FamilyMembers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: a member's profile photo. The endpoint accepts any of the
|
||||||
|
/// images allowed by <see cref="FilesOptions"/>, decodes them with
|
||||||
|
/// ImageSharp, crops them to a 512×512 square and re-encodes as JPEG so
|
||||||
|
/// every avatar has a uniform shape and size on disk regardless of source.
|
||||||
|
/// Reused by both the admin (any member) and self (the linked user)
|
||||||
|
/// permissions paths.
|
||||||
|
/// </summary>
|
||||||
|
public static class UploadMemberPhoto
|
||||||
|
{
|
||||||
|
/// <summary>Final stored size for every avatar (square).</summary>
|
||||||
|
public const int OutputSize = 512;
|
||||||
|
|
||||||
|
/// <summary>JPEG quality for the re-encoded avatar.</summary>
|
||||||
|
private const int JpegQuality = 88;
|
||||||
|
|
||||||
|
/// <summary>Subfolder under the files volume where avatars live.</summary>
|
||||||
|
private const string AvatarsFolder = "members";
|
||||||
|
|
||||||
|
/// <summary>Command.</summary>
|
||||||
|
/// <param name="MemberId">Target member id.</param>
|
||||||
|
/// <param name="Stream">Input image stream (raw bytes from multipart).</param>
|
||||||
|
/// <param name="ContentType">Reported MIME type from the multipart part.</param>
|
||||||
|
/// <param name="LengthBytes">Reported byte count from the multipart part.</param>
|
||||||
|
public sealed record Command(
|
||||||
|
Guid MemberId,
|
||||||
|
Stream Stream,
|
||||||
|
string ContentType,
|
||||||
|
long LengthBytes) : IRequest<Result<FamilyMemberDto>>;
|
||||||
|
|
||||||
|
/// <summary>Handler.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
private readonly FilesOptions _filesOptions;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ICurrentUserContext userContext,
|
||||||
|
IOptions<FilesOptions> filesOptions)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
_filesOptions = filesOptions.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(ct);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await _db.FamilyMembers
|
||||||
|
.Include(m => m.User)
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, ct);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("family_member.not_found", "Member not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization: the admin can change anyone's photo; a regular user
|
||||||
|
// can only change their *own* member's photo.
|
||||||
|
var isAdmin = current.User.Role == FamilyRole.Admin;
|
||||||
|
var isSelf = current.Member.Id == member.Id;
|
||||||
|
if (!isAdmin && !isSelf)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden(
|
||||||
|
"family_member.not_self_or_admin",
|
||||||
|
"Only an admin or the member themself can change this photo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME + size guards. We trust the multipart-supplied content-type
|
||||||
|
// here (it's checked again by ImageSharp when decoding); a wrong
|
||||||
|
// type would also fail to decode and surface a validation error.
|
||||||
|
if (request.LengthBytes <= 0)
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation("photo.empty", "Empty file.");
|
||||||
|
}
|
||||||
|
if (request.LengthBytes > _filesOptions.MaxImageBytes)
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation("photo.too_large", "File exceeds the configured size limit.");
|
||||||
|
}
|
||||||
|
if (!_filesOptions.AllowedImageTypes.Contains(request.ContentType, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation("photo.unsupported_type", $"Content type '{request.ContentType}' not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode → resize+crop center → JPEG. ImageSharp throws on malformed
|
||||||
|
// input; the endpoint wrapper catches and surfaces a clean 400.
|
||||||
|
using var image = await Image.LoadAsync(request.Stream, ct);
|
||||||
|
image.Mutate(ctx => ctx.Resize(new ResizeOptions
|
||||||
|
{
|
||||||
|
Mode = ResizeMode.Crop,
|
||||||
|
Size = new Size(OutputSize, OutputSize),
|
||||||
|
Sampler = KnownResamplers.Lanczos3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Persist as members/{memberId}.jpg, overwriting any previous photo
|
||||||
|
// so we never accumulate stale files. The path is deterministic so
|
||||||
|
// the static-file middleware can cache it (and busts via the entity
|
||||||
|
// tag when the bytes change).
|
||||||
|
var folder = Path.Combine(_filesOptions.StorageRoot, AvatarsFolder);
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
var fileName = $"{member.Id:N}.jpg";
|
||||||
|
var fullPath = Path.Combine(folder, fileName);
|
||||||
|
|
||||||
|
await using (var output = File.Create(fullPath))
|
||||||
|
{
|
||||||
|
var encoder = new JpegEncoder { Quality = JpegQuality };
|
||||||
|
await image.SaveAsync(output, encoder, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
member.PhotoPath = $"{AvatarsFolder}/{fileName}";
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return FamilyMemberDto.From(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/FamilyNido.Api/Features/Files/DownloadFile.cs
Normal file
78
src/FamilyNido.Api/Features/Files/DownloadFile.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Files;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: open the bytes of a <see cref="Domain.Files.FileAsset"/> for streaming back
|
||||||
|
/// to an authorized caller. Enforces per-family scoping: the asset must belong to the
|
||||||
|
/// caller's family (404 otherwise — indistinguishable from "does not exist").
|
||||||
|
/// </summary>
|
||||||
|
public static class DownloadFile
|
||||||
|
{
|
||||||
|
/// <summary>Resolved payload delivered to the endpoint to stream back.</summary>
|
||||||
|
/// <param name="Stream">Open readable stream; disposed by the endpoint after sending.</param>
|
||||||
|
/// <param name="ContentType">MIME type to set on the response.</param>
|
||||||
|
/// <param name="SizeBytes">Size of the stream in bytes.</param>
|
||||||
|
public sealed record FilePayload(Stream Stream, string ContentType, long SizeBytes);
|
||||||
|
|
||||||
|
/// <summary>Query carrying the target id.</summary>
|
||||||
|
public sealed record Query(Guid FileId) : IRequest<Result<FilePayload>>;
|
||||||
|
|
||||||
|
/// <summary>Handler — looks up the asset and opens a read stream on disk.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Query, Result<FilePayload>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
private readonly FilesOptions _options;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ICurrentUserContext userContext,
|
||||||
|
IOptions<FilesOptions> options)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FilePayload>> HandleAsync(Query request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var asset = await _db.FileAssets
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == request.FileId && a.FamilyId == current.Family.Id,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (asset is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("file.not_found", $"File {request.FileId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var absolutePath = Path.Combine(_options.StorageRoot, asset.RelativePath);
|
||||||
|
if (!File.Exists(absolutePath))
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound(
|
||||||
|
"file.bytes_missing",
|
||||||
|
$"File {request.FileId} metadata exists but bytes are missing on disk.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = File.OpenRead(absolutePath);
|
||||||
|
return new FilePayload(stream, asset.ContentType, asset.SizeBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/FamilyNido.Api/Features/Files/FileAssetDto.cs
Normal file
31
src/FamilyNido.Api/Features/Files/FileAssetDto.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
using FamilyNido.Domain.Files;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Files;
|
||||||
|
|
||||||
|
/// <summary>Read-model projection of a <see cref="FileAsset"/> returned by the API.</summary>
|
||||||
|
/// <param name="Id">Stable asset identifier.</param>
|
||||||
|
/// <param name="ContentType">MIME type of the stored bytes.</param>
|
||||||
|
/// <param name="SizeBytes">Size of the stored bytes.</param>
|
||||||
|
/// <param name="Width">Image width in pixels, if known.</param>
|
||||||
|
/// <param name="Height">Image height in pixels, if known.</param>
|
||||||
|
/// <param name="OwnerMemberId">Member who uploaded the file.</param>
|
||||||
|
/// <param name="Url">Relative URL the Angular client uses to load the bytes.</param>
|
||||||
|
public sealed record FileAssetDto(
|
||||||
|
Guid Id,
|
||||||
|
string ContentType,
|
||||||
|
long SizeBytes,
|
||||||
|
int? Width,
|
||||||
|
int? Height,
|
||||||
|
Guid OwnerMemberId,
|
||||||
|
string Url)
|
||||||
|
{
|
||||||
|
/// <summary>Project a domain entity to the DTO shape used by the API.</summary>
|
||||||
|
public static FileAssetDto From(FileAsset a) => new(
|
||||||
|
Id: a.Id,
|
||||||
|
ContentType: a.ContentType,
|
||||||
|
SizeBytes: a.SizeBytes,
|
||||||
|
Width: a.Width,
|
||||||
|
Height: a.Height,
|
||||||
|
OwnerMemberId: a.OwnerMemberId,
|
||||||
|
Url: $"/api/files/{a.Id}");
|
||||||
|
}
|
||||||
74
src/FamilyNido.Api/Features/Files/FileEndpoints.cs
Normal file
74
src/FamilyNido.Api/Features/Files/FileEndpoints.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Http;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Files;
|
||||||
|
|
||||||
|
/// <summary>REST endpoints for the shared file-asset module.</summary>
|
||||||
|
public static class FileEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Registers <c>/api/files</c> endpoints on the given route group.</summary>
|
||||||
|
public static IEndpointRouteBuilder MapFileEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/files")
|
||||||
|
.WithTags("Files")
|
||||||
|
.RequireAuthorization(Policies.AuthenticatedUser);
|
||||||
|
|
||||||
|
// multipart/form-data: single "file" part + optional "module" (default "wall").
|
||||||
|
group.MapPost("/", UploadAsync).DisableAntiforgery();
|
||||||
|
group.MapGet("/{id:guid}", DownloadAsync);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UploadAsync(
|
||||||
|
HttpRequest request,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!request.HasFormContentType)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { code = "file.missing_multipart", message = "Upload must be multipart/form-data." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = await request.ReadFormAsync(ct);
|
||||||
|
var file = form.Files.GetFile("file");
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { code = "file.missing", message = "No file provided." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var module = form["module"].ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(module))
|
||||||
|
{
|
||||||
|
module = "wall";
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
var command = new UploadFile.Command(stream, file.ContentType, file.Length, module);
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Created(result.Value.Url, result.Value)
|
||||||
|
: result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DownloadAsync(
|
||||||
|
Guid id,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new DownloadFile.Query(id), ct);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
return result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = result.Value;
|
||||||
|
// Results.Stream disposes the stream after writing; caches for 1 day —
|
||||||
|
// fine because the URL carries the id and file content is immutable once written.
|
||||||
|
return Results.Stream(
|
||||||
|
payload.Stream,
|
||||||
|
contentType: payload.ContentType,
|
||||||
|
enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/FamilyNido.Api/Features/Files/UploadFile.cs
Normal file
115
src/FamilyNido.Api/Features/Files/UploadFile.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Options;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Files;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Files;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice: persist an uploaded image to the configured storage root + create a
|
||||||
|
/// <see cref="FileAsset"/> metadata row. Files are scoped per family (used later
|
||||||
|
/// to authorize downloads) and placed under a module-specific subfolder
|
||||||
|
/// (<c>wall/yyyy/MM/<uuid>.ext</c>). First consumer is the wall module; the
|
||||||
|
/// same endpoint will back recipes and health attachments later on.
|
||||||
|
/// </summary>
|
||||||
|
public static class UploadFile
|
||||||
|
{
|
||||||
|
/// <summary>Command carrying the raw stream + metadata.</summary>
|
||||||
|
/// <param name="Stream">Open readable stream positioned at the start of the file.</param>
|
||||||
|
/// <param name="ContentType">MIME type as declared by the client.</param>
|
||||||
|
/// <param name="SizeBytes">Size of the stream in bytes.</param>
|
||||||
|
/// <param name="Module">Logical folder under the storage root (e.g. <c>wall</c>).</param>
|
||||||
|
public sealed record Command(
|
||||||
|
Stream Stream,
|
||||||
|
string ContentType,
|
||||||
|
long SizeBytes,
|
||||||
|
string Module) : IRequest<Result<FileAssetDto>>;
|
||||||
|
|
||||||
|
/// <summary>Handler — writes to disk then persists the asset row.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<FileAssetDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
private readonly FilesOptions _options;
|
||||||
|
private readonly TimeProvider _clock;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ICurrentUserContext userContext,
|
||||||
|
IOptions<FilesOptions> options,
|
||||||
|
TimeProvider clock)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
_options = options.Value;
|
||||||
|
_clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<FileAssetDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_options.AllowedImageTypes.Contains(request.ContentType, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation(
|
||||||
|
"file.unsupported_content_type",
|
||||||
|
$"Content type '{request.ContentType}' is not accepted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.SizeBytes <= 0 || request.SizeBytes > _options.MaxImageBytes)
|
||||||
|
{
|
||||||
|
return ApplicationError.Validation(
|
||||||
|
"file.size_out_of_range",
|
||||||
|
$"File size must be between 1 byte and {_options.MaxImageBytes} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetId = Guid.CreateVersion7();
|
||||||
|
var now = _clock.GetUtcNow();
|
||||||
|
var extension = ExtensionFor(request.ContentType);
|
||||||
|
var relativePath = $"{request.Module}/{now.Year:D4}/{now.Month:D2}/{assetId}{extension}";
|
||||||
|
var absolutePath = Path.Combine(_options.StorageRoot, relativePath);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)!);
|
||||||
|
|
||||||
|
await using (var fs = File.Create(absolutePath))
|
||||||
|
{
|
||||||
|
await request.Stream.CopyToAsync(fs, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var asset = new FileAsset
|
||||||
|
{
|
||||||
|
Id = assetId,
|
||||||
|
FamilyId = current.Family.Id,
|
||||||
|
OwnerMemberId = current.Member.Id,
|
||||||
|
RelativePath = relativePath,
|
||||||
|
ContentType = request.ContentType,
|
||||||
|
SizeBytes = request.SizeBytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.FileAssets.Add(asset);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return FileAssetDto.From(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtensionFor(string contentType) => contentType.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"image/jpeg" => ".jpg",
|
||||||
|
"image/png" => ".png",
|
||||||
|
"image/webp" => ".webp",
|
||||||
|
"image/heic" => ".heic",
|
||||||
|
"image/heif" => ".heif",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/FamilyNido.Api/Features/Health/AddMedication.cs
Normal file
93
src/FamilyNido.Api/Features/Health/AddMedication.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Health;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Health;
|
||||||
|
|
||||||
|
/// <summary>Slice for <c>POST /api/health/members/{memberId}/medications</c>.</summary>
|
||||||
|
public static class AddMedication
|
||||||
|
{
|
||||||
|
/// <summary>Command for a new medication row.</summary>
|
||||||
|
public sealed record Command(
|
||||||
|
Guid MemberId,
|
||||||
|
string Name,
|
||||||
|
string? Dose,
|
||||||
|
string? Frequency,
|
||||||
|
DateOnly StartDate,
|
||||||
|
DateOnly? EndDate,
|
||||||
|
string? Instructions) : IRequest<Result<MedicationDto>>;
|
||||||
|
|
||||||
|
/// <summary>Input validation.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator with all rules registered.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(120);
|
||||||
|
RuleFor(x => x.Dose).MaximumLength(80);
|
||||||
|
RuleFor(x => x.Frequency).MaximumLength(120);
|
||||||
|
RuleFor(x => x.Instructions).MaximumLength(2000);
|
||||||
|
RuleFor(x => x.EndDate)
|
||||||
|
.GreaterThanOrEqualTo(x => x.StartDate)
|
||||||
|
.When(x => x.EndDate is not null)
|
||||||
|
.WithMessage("La fecha de fin debe ser posterior o igual a la de inicio.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Inserts after verifying family scope.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<MedicationDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<MedicationDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var memberOk = await _db.FamilyMembers
|
||||||
|
.AnyAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, cancellationToken);
|
||||||
|
if (!memberOk)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("family_member.not_found", $"Member {request.MemberId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new Medication
|
||||||
|
{
|
||||||
|
FamilyMemberId = request.MemberId,
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Dose = Trim(request.Dose),
|
||||||
|
Frequency = Trim(request.Frequency),
|
||||||
|
StartDate = request.StartDate,
|
||||||
|
EndDate = request.EndDate,
|
||||||
|
Instructions = Trim(request.Instructions),
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Medications.Add(entry);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var isActive = entry.EndDate is null || entry.EndDate >= today;
|
||||||
|
return new MedicationDto(entry.Id, entry.Name, entry.Dose, entry.Frequency, entry.StartDate, entry.EndDate, entry.Instructions, isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Trim(string? value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/FamilyNido.Api/Features/Health/AddVaccination.cs
Normal file
82
src/FamilyNido.Api/Features/Health/AddVaccination.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Domain.Health;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Health;
|
||||||
|
|
||||||
|
/// <summary>Slice for <c>POST /api/health/members/{memberId}/vaccinations</c>.</summary>
|
||||||
|
public static class AddVaccination
|
||||||
|
{
|
||||||
|
/// <summary>Command for a new vaccination row.</summary>
|
||||||
|
public sealed record Command(
|
||||||
|
Guid MemberId,
|
||||||
|
string Name,
|
||||||
|
DateOnly Date,
|
||||||
|
DateOnly? NextDueDate,
|
||||||
|
string? Notes) : IRequest<Result<VaccinationDto>>;
|
||||||
|
|
||||||
|
/// <summary>Input validation.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator with all rules registered.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(120);
|
||||||
|
RuleFor(x => x.Notes).MaximumLength(2000);
|
||||||
|
RuleFor(x => x.NextDueDate)
|
||||||
|
.GreaterThanOrEqualTo(x => x.Date)
|
||||||
|
.When(x => x.NextDueDate is not null)
|
||||||
|
.WithMessage("La próxima dosis debe ser posterior o igual a la fecha actual.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Inserts after verifying family scope.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<VaccinationDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<VaccinationDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var memberOk = await _db.FamilyMembers
|
||||||
|
.AnyAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, cancellationToken);
|
||||||
|
if (!memberOk)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("family_member.not_found", $"Member {request.MemberId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new Vaccination
|
||||||
|
{
|
||||||
|
FamilyMemberId = request.MemberId,
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Date = request.Date,
|
||||||
|
NextDueDate = request.NextDueDate,
|
||||||
|
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Vaccinations.Add(entry);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new VaccinationDto(entry.Id, entry.Name, entry.Date, entry.NextDueDate, entry.Notes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/FamilyNido.Api/Features/Health/DeleteMedication.cs
Normal file
54
src/FamilyNido.Api/Features/Health/DeleteMedication.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Health;
|
||||||
|
|
||||||
|
/// <summary>Slice for <c>DELETE /api/health/medications/{id}</c>.</summary>
|
||||||
|
public static class DeleteMedication
|
||||||
|
{
|
||||||
|
/// <summary>Command carrying the row to delete.</summary>
|
||||||
|
public sealed record Command(Guid Id) : IRequest<Result<Unit>>;
|
||||||
|
|
||||||
|
/// <summary>Empty success payload.</summary>
|
||||||
|
public sealed record Unit;
|
||||||
|
|
||||||
|
/// <summary>Removes the row after verifying family scope.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = await _db.Medications
|
||||||
|
.Include(m => m.FamilyMember)
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == request.Id, cancellationToken);
|
||||||
|
if (entry is null || entry.FamilyMember!.FamilyId != current.Family.Id)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("medication.not_found", $"Medication {request.Id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.Medications.Remove(entry);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return new Unit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/FamilyNido.Api/Features/Health/DeleteVaccination.cs
Normal file
54
src/FamilyNido.Api/Features/Health/DeleteVaccination.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Health;
|
||||||
|
|
||||||
|
/// <summary>Slice for <c>DELETE /api/health/vaccinations/{id}</c>.</summary>
|
||||||
|
public static class DeleteVaccination
|
||||||
|
{
|
||||||
|
/// <summary>Command carrying the row to delete.</summary>
|
||||||
|
public sealed record Command(Guid Id) : IRequest<Result<Unit>>;
|
||||||
|
|
||||||
|
/// <summary>Empty success payload.</summary>
|
||||||
|
public sealed record Unit;
|
||||||
|
|
||||||
|
/// <summary>Removes the row after verifying family scope.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = await _db.Vaccinations
|
||||||
|
.Include(v => v.FamilyMember)
|
||||||
|
.FirstOrDefaultAsync(v => v.Id == request.Id, cancellationToken);
|
||||||
|
if (entry is null || entry.FamilyMember!.FamilyId != current.Family.Id)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("vaccination.not_found", $"Vaccination {request.Id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.Vaccinations.Remove(entry);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return new Unit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/FamilyNido.Api/Features/Health/GetMemberHealth.cs
Normal file
82
src/FamilyNido.Api/Features/Health/GetMemberHealth.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice for <c>GET /api/health/members/{memberId}</c>. Returns the member's
|
||||||
|
/// health card with profile, vaccinations and medications in a single
|
||||||
|
/// request — the screen needs all three at once so paging would buy nothing.
|
||||||
|
/// </summary>
|
||||||
|
public static class GetMemberHealth
|
||||||
|
{
|
||||||
|
/// <summary>Query carrying the target member id.</summary>
|
||||||
|
public sealed record Query(Guid MemberId) : IRequest<Result<MemberHealthDto>>;
|
||||||
|
|
||||||
|
/// <summary>Resolves the member, validates family scope, hydrates the DTO.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Query, Result<MemberHealthDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<MemberHealthDto>> HandleAsync(Query request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await _db.FamilyMembers
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, cancellationToken);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("family_member.not_found", $"Member {request.MemberId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = await _db.HealthProfiles
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => p.FamilyMemberId == request.MemberId)
|
||||||
|
.Select(p => new HealthProfileDto(p.BloodType, p.Allergies, p.ChronicConditions, p.Notes))
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var vaccinations = await _db.Vaccinations
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(v => v.FamilyMemberId == request.MemberId)
|
||||||
|
.OrderByDescending(v => v.Date)
|
||||||
|
.Select(v => new VaccinationDto(v.Id, v.Name, v.Date, v.NextDueDate, v.Notes))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var medications = await _db.Medications
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(m => m.FamilyMemberId == request.MemberId)
|
||||||
|
.OrderByDescending(m => m.StartDate)
|
||||||
|
.Select(m => new MedicationDto(
|
||||||
|
m.Id,
|
||||||
|
m.Name,
|
||||||
|
m.Dose,
|
||||||
|
m.Frequency,
|
||||||
|
m.StartDate,
|
||||||
|
m.EndDate,
|
||||||
|
m.Instructions,
|
||||||
|
m.EndDate == null || m.EndDate >= today))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new MemberHealthDto(member.Id, member.DisplayName, profile, vaccinations, medications);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/FamilyNido.Api/Features/Health/HealthDtos.cs
Normal file
57
src/FamilyNido.Api/Features/Health/HealthDtos.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
namespace FamilyNido.Api.Features.Health;
|
||||||
|
|
||||||
|
/// <summary>Aggregate "health card" for a single member returned by GET /api/health/members/{id}.</summary>
|
||||||
|
/// <param name="MemberId">Family member id.</param>
|
||||||
|
/// <param name="MemberDisplayName">Convenience copy of the member's name for headers.</param>
|
||||||
|
/// <param name="Profile">Static profile (blood, allergies, conditions, notes). Null when never saved.</param>
|
||||||
|
/// <param name="Vaccinations">Vaccinations sorted by date descending.</param>
|
||||||
|
/// <param name="Medications">Medications sorted by start date descending.</param>
|
||||||
|
public sealed record MemberHealthDto(
|
||||||
|
Guid MemberId,
|
||||||
|
string MemberDisplayName,
|
||||||
|
HealthProfileDto? Profile,
|
||||||
|
IReadOnlyList<VaccinationDto> Vaccinations,
|
||||||
|
IReadOnlyList<MedicationDto> Medications);
|
||||||
|
|
||||||
|
/// <summary>Body of the static health profile.</summary>
|
||||||
|
/// <param name="BloodType">"A+", "O-", "AB+"… or null when unknown.</param>
|
||||||
|
/// <param name="Allergies">Free-text list of allergies (multi-line allowed).</param>
|
||||||
|
/// <param name="ChronicConditions">Free-text list of chronic conditions.</param>
|
||||||
|
/// <param name="Notes">Free-text general notes.</param>
|
||||||
|
public sealed record HealthProfileDto(
|
||||||
|
string? BloodType,
|
||||||
|
string? Allergies,
|
||||||
|
string? ChronicConditions,
|
||||||
|
string? Notes);
|
||||||
|
|
||||||
|
/// <summary>Single vaccination row.</summary>
|
||||||
|
/// <param name="Id">Stable id.</param>
|
||||||
|
/// <param name="Name">Vaccine name.</param>
|
||||||
|
/// <param name="Date">Date administered.</param>
|
||||||
|
/// <param name="NextDueDate">Next due date when known.</param>
|
||||||
|
/// <param name="Notes">Optional notes.</param>
|
||||||
|
public sealed record VaccinationDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
DateOnly Date,
|
||||||
|
DateOnly? NextDueDate,
|
||||||
|
string? Notes);
|
||||||
|
|
||||||
|
/// <summary>Single medication row.</summary>
|
||||||
|
/// <param name="Id">Stable id.</param>
|
||||||
|
/// <param name="Name">Medication name.</param>
|
||||||
|
/// <param name="Dose">Dose string (free text).</param>
|
||||||
|
/// <param name="Frequency">Frequency string (free text).</param>
|
||||||
|
/// <param name="StartDate">Start date.</param>
|
||||||
|
/// <param name="EndDate">End date when finite.</param>
|
||||||
|
/// <param name="Instructions">Optional instructions.</param>
|
||||||
|
/// <param name="IsActive">Computed: true when EndDate is null or >= today (in UTC).</param>
|
||||||
|
public sealed record MedicationDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Dose,
|
||||||
|
string? Frequency,
|
||||||
|
DateOnly StartDate,
|
||||||
|
DateOnly? EndDate,
|
||||||
|
string? Instructions,
|
||||||
|
bool IsActive);
|
||||||
159
src/FamilyNido.Api/Features/Health/HealthEndpoints.cs
Normal file
159
src/FamilyNido.Api/Features/Health/HealthEndpoints.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Http;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Endpoints for the health module. Gated to Admin/Adult roles — Guests
|
||||||
|
/// (children with their own accounts in the future) never see this surface.
|
||||||
|
/// </summary>
|
||||||
|
public static class HealthEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Registers <c>/api/health/*</c> routes on the given builder.</summary>
|
||||||
|
public static IEndpointRouteBuilder MapHealthEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/health").WithTags("Health");
|
||||||
|
|
||||||
|
group.MapGet("/members/{memberId:guid}", GetMemberAsync)
|
||||||
|
.RequireAuthorization(Policies.Adult);
|
||||||
|
|
||||||
|
group.MapPut("/members/{memberId:guid}/profile", UpsertProfileAsync)
|
||||||
|
.RequireAuthorization(Policies.Adult);
|
||||||
|
|
||||||
|
group.MapPost("/members/{memberId:guid}/vaccinations", AddVaccinationAsync)
|
||||||
|
.RequireAuthorization(Policies.Adult);
|
||||||
|
|
||||||
|
group.MapPut("/vaccinations/{id:guid}", UpdateVaccinationAsync)
|
||||||
|
.RequireAuthorization(Policies.Adult);
|
||||||
|
|
||||||
|
group.MapDelete("/vaccinations/{id:guid}", DeleteVaccinationAsync)
|
||||||
|
.RequireAuthorization(Policies.Adult);
|
||||||
|
|
||||||
|
group.MapPost("/members/{memberId:guid}/medications", AddMedicationAsync)
|
||||||
|
.RequireAuthorization(Policies.Adult);
|
||||||
|
|
||||||
|
group.MapPut("/medications/{id:guid}", UpdateMedicationAsync)
|
||||||
|
.RequireAuthorization(Policies.Adult);
|
||||||
|
|
||||||
|
group.MapDelete("/medications/{id:guid}", DeleteMedicationAsync)
|
||||||
|
.RequireAuthorization(Policies.Adult);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetMemberAsync(Guid memberId, IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new GetMemberHealth.Query(memberId), ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpsertProfileAsync(
|
||||||
|
Guid memberId,
|
||||||
|
UpsertHealthProfileBody body,
|
||||||
|
IValidator<UpsertHealthProfile.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var command = new UpsertHealthProfile.Command(
|
||||||
|
memberId, body.BloodType, body.Allergies, body.ChronicConditions, body.Notes);
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AddVaccinationAsync(
|
||||||
|
Guid memberId,
|
||||||
|
VaccinationBody body,
|
||||||
|
IValidator<AddVaccination.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var command = new AddVaccination.Command(memberId, body.Name, body.Date, body.NextDueDate, body.Notes);
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateVaccinationAsync(
|
||||||
|
Guid id,
|
||||||
|
VaccinationBody body,
|
||||||
|
IValidator<UpdateVaccination.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var command = new UpdateVaccination.Command(id, body.Name, body.Date, body.NextDueDate, body.Notes);
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteVaccinationAsync(Guid id, IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new DeleteVaccination.Command(id), ct);
|
||||||
|
return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AddMedicationAsync(
|
||||||
|
Guid memberId,
|
||||||
|
MedicationBody body,
|
||||||
|
IValidator<AddMedication.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var command = new AddMedication.Command(
|
||||||
|
memberId, body.Name, body.Dose, body.Frequency, body.StartDate, body.EndDate, body.Instructions);
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateMedicationAsync(
|
||||||
|
Guid id,
|
||||||
|
MedicationBody body,
|
||||||
|
IValidator<UpdateMedication.Command> validator,
|
||||||
|
IMediator mediator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var command = new UpdateMedication.Command(
|
||||||
|
id, body.Name, body.Dose, body.Frequency, body.StartDate, body.EndDate, body.Instructions);
|
||||||
|
var validation = await validator.ValidateOrProblemAsync(command, ct);
|
||||||
|
if (validation is not null) return validation;
|
||||||
|
var result = await mediator.SendAsync(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteMedicationAsync(Guid id, IMediator mediator, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await mediator.SendAsync(new DeleteMedication.Command(id), ct);
|
||||||
|
return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Body for the health-profile upsert (memberId comes from the route).</summary>
|
||||||
|
public sealed record UpsertHealthProfileBody(
|
||||||
|
string? BloodType,
|
||||||
|
string? Allergies,
|
||||||
|
string? ChronicConditions,
|
||||||
|
string? Notes);
|
||||||
|
|
||||||
|
/// <summary>Body shared by add/update vaccination (memberId/id come from the route).</summary>
|
||||||
|
public sealed record VaccinationBody(
|
||||||
|
string Name,
|
||||||
|
DateOnly Date,
|
||||||
|
DateOnly? NextDueDate,
|
||||||
|
string? Notes);
|
||||||
|
|
||||||
|
/// <summary>Body shared by add/update medication.</summary>
|
||||||
|
public sealed record MedicationBody(
|
||||||
|
string Name,
|
||||||
|
string? Dose,
|
||||||
|
string? Frequency,
|
||||||
|
DateOnly StartDate,
|
||||||
|
DateOnly? EndDate,
|
||||||
|
string? Instructions);
|
||||||
|
}
|
||||||
88
src/FamilyNido.Api/Features/Health/UpdateMedication.cs
Normal file
88
src/FamilyNido.Api/Features/Health/UpdateMedication.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
using FamilyNido.Api.Features.Auth;
|
||||||
|
using FamilyNido.Api.Shared.Errors;
|
||||||
|
using FamilyNido.Api.Shared.Mediator;
|
||||||
|
using FamilyNido.Api.Shared.Outcomes;
|
||||||
|
using FamilyNido.Persistence;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FamilyNido.Api.Features.Health;
|
||||||
|
|
||||||
|
/// <summary>Slice for <c>PUT /api/health/medications/{id}</c>.</summary>
|
||||||
|
public static class UpdateMedication
|
||||||
|
{
|
||||||
|
/// <summary>Command replacing the editable fields of a medication row.</summary>
|
||||||
|
public sealed record Command(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Dose,
|
||||||
|
string? Frequency,
|
||||||
|
DateOnly StartDate,
|
||||||
|
DateOnly? EndDate,
|
||||||
|
string? Instructions) : IRequest<Result<MedicationDto>>;
|
||||||
|
|
||||||
|
/// <summary>Input validation.</summary>
|
||||||
|
public sealed class Validator : AbstractValidator<Command>
|
||||||
|
{
|
||||||
|
/// <summary>Creates the validator with all rules registered.</summary>
|
||||||
|
public Validator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty();
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(120);
|
||||||
|
RuleFor(x => x.Dose).MaximumLength(80);
|
||||||
|
RuleFor(x => x.Frequency).MaximumLength(120);
|
||||||
|
RuleFor(x => x.Instructions).MaximumLength(2000);
|
||||||
|
RuleFor(x => x.EndDate)
|
||||||
|
.GreaterThanOrEqualTo(x => x.StartDate)
|
||||||
|
.When(x => x.EndDate is not null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates after verifying family scope.</summary>
|
||||||
|
public sealed class Handler : IRequestHandler<Command, Result<MedicationDto>>
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ICurrentUserContext _userContext;
|
||||||
|
|
||||||
|
/// <summary>Primary constructor.</summary>
|
||||||
|
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<MedicationDto>> HandleAsync(Command request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var current = await _userContext.GetAsync(cancellationToken);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = await _db.Medications
|
||||||
|
.Include(m => m.FamilyMember)
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == request.Id, cancellationToken);
|
||||||
|
if (entry is null || entry.FamilyMember!.FamilyId != current.Family.Id)
|
||||||
|
{
|
||||||
|
return ApplicationError.NotFound("medication.not_found", $"Medication {request.Id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Name = request.Name.Trim();
|
||||||
|
entry.Dose = Trim(request.Dose);
|
||||||
|
entry.Frequency = Trim(request.Frequency);
|
||||||
|
entry.StartDate = request.StartDate;
|
||||||
|
entry.EndDate = request.EndDate;
|
||||||
|
entry.Instructions = Trim(request.Instructions);
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var isActive = entry.EndDate is null || entry.EndDate >= today;
|
||||||
|
return new MedicationDto(entry.Id, entry.Name, entry.Dose, entry.Frequency, entry.StartDate, entry.EndDate, entry.Instructions, isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Trim(string? value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue