commit a308041d59518731f49810634511a84a2c7f8cca Author: Pablo Fernรกndez Date: Wed May 13 00:14:26 2026 +0200 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. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72f54d5 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..af3d700 --- /dev/null +++ b/.editorconfig @@ -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 + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7c6b56b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4946c4a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +# NOTE: update the URLs below to match the actual / 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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..f9163b3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0a6a12a --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..8ed52e8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ + + +## Summary + + + +## Linked issue + + + +## How was this tested? + + + +## Screenshots / clips (UI changes only) + + diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..4a91c9b --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -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 }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..dc98e23 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -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 < 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..123498d --- /dev/null +++ b/.gitignore @@ -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/ + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1d6ca12 --- /dev/null +++ b/CLAUDE.md @@ -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 `` and `` 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 `. +- 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. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..122a8ec --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..120d3c9 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,16 @@ + + + net10.0 + latest + enable + enable + true + + true + true + $(NoWarn);CS1591 + latest + true + false + + diff --git a/FamilyNido.slnx b/FamilyNido.slnx new file mode 100644 index 0000000..06d0880 --- /dev/null +++ b/FamilyNido.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c837a38 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ade2a9 --- /dev/null +++ b/README.md @@ -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. + +![FamilyNido home dashboard](docs/screenshots/hero-dashboard.png) + +--- + +## โœจ 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. + +![Family wall with posts, reactions and comments](docs/screenshots/wall.png) + +### โœ… 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. + +![Weekly meal plan grid](docs/screenshots/meals.png) + +### ๐Ÿฉบ 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. + +![Tablet mode rotating dashboard](docs/screenshots/tablet-mode.png) + +### ๐Ÿ  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. + +
+End-to-end tests (Playwright) โ€” click to expand + +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. + +
+ +--- + +## ๐Ÿšข 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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..77ed184 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..b1117c2 --- /dev/null +++ b/deploy/.env.example @@ -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//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 +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 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..efbf81c --- /dev/null +++ b/deploy/README.md @@ -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//familynido-api:latest` +- `ghcr.io//familynido-web:latest` + +Each push also produces an immutable `sha-` 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 | docker login ghcr.io -u --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:///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:///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-`. 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. diff --git a/deploy/api-entrypoint.sh b/deploy/api-entrypoint.sh new file mode 100644 index 0000000..a28e26e --- /dev/null +++ b/deploy/api-entrypoint.sh @@ -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 "$@" diff --git a/deploy/api.Dockerfile b/deploy/api.Dockerfile new file mode 100644 index 0000000..b912c47 --- /dev/null +++ b/deploy/api.Dockerfile @@ -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"] diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml new file mode 100644 index 0000000..32abd9b --- /dev/null +++ b/deploy/docker-compose.dev.yml @@ -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: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..dc969b6 --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -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 } + 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 diff --git a/deploy/nginx/default.conf b/deploy/nginx/default.conf new file mode 100644 index 0000000..dfb8288 --- /dev/null +++ b/deploy/nginx/default.conf @@ -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://: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; + } +} diff --git a/deploy/web.Dockerfile b/deploy/web.Dockerfile new file mode 100644 index 0000000..f2321ba --- /dev/null +++ b/deploy/web.Dockerfile @@ -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;"] diff --git a/docs/integrations/README.md b/docs/integrations/README.md new file mode 100644 index 0000000..d1cf966 --- /dev/null +++ b/docs/integrations/README.md @@ -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. diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md new file mode 100644 index 0000000..8b67bd8 --- /dev/null +++ b/docs/screenshots/README.md @@ -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. diff --git a/docs/screenshots/hero-dashboard.png b/docs/screenshots/hero-dashboard.png new file mode 100644 index 0000000..a56a143 Binary files /dev/null and b/docs/screenshots/hero-dashboard.png differ diff --git a/docs/screenshots/meals.png b/docs/screenshots/meals.png new file mode 100644 index 0000000..d84ca4b Binary files /dev/null and b/docs/screenshots/meals.png differ diff --git a/docs/screenshots/tablet-mode.png b/docs/screenshots/tablet-mode.png new file mode 100644 index 0000000..38d9555 Binary files /dev/null and b/docs/screenshots/tablet-mode.png differ diff --git a/docs/screenshots/wall.png b/docs/screenshots/wall.png new file mode 100644 index 0000000..7060160 Binary files /dev/null and b/docs/screenshots/wall.png differ diff --git a/global.json b/global.json new file mode 100644 index 0000000..067010d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.102", + "rollForward": "latestFeature" + } +} diff --git a/src/FamilyNido.Api/Bootstrap/DemoDataSeeder.cs b/src/FamilyNido.Api/Bootstrap/DemoDataSeeder.cs new file mode 100644 index 0000000..c1aa138 --- /dev/null +++ b/src/FamilyNido.Api/Bootstrap/DemoDataSeeder.cs @@ -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; + +/// +/// Hosted service that drops a curated, screenshot-friendly scenario into an +/// empty database when running under the Development environment with +/// Seed:Demo:Enabled=true. Exists so the README screenshots can be +/// captured against a coherent fake family without anybody having to expose +/// their real data. +/// +/// +/// 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 +/// (docker compose -f deploy/docker-compose.dev.yml down -v && up -d) +/// and restarting the API. +/// 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. +/// +public sealed class DemoDataSeeder : IHostedService +{ + private readonly IServiceProvider _services; + private readonly DemoSeedOptions _options; + private readonly ILogger _logger; + + /// Primary constructor. + public DemoDataSeeder( + IServiceProvider services, + IOptions options, + ILogger logger) + { + _services = services; + _options = options.Value; + _logger = logger; + } + + /// + 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(); + var hasher = scope.ServiceProvider.GetRequiredService>(); + var markdown = scope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task 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 SeedMembersAsync( + ApplicationDbContext db, + IPasswordHasher 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 CreateAdultAsync( + ApplicationDbContext db, + IPasswordHasher 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 + { + // 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 + { + 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 CreateWallMessageAsync( + ApplicationDbContext db, + MarkdownRenderer markdown, + Family family, + FamilyMember author, + string source, + bool isPinned, + DateTimeOffset createdAt, + IReadOnlyList 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 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); + } + + /// Bundle of seeded members so the rest of the seeder can refer to + /// them by role without juggling four parameters everywhere. + private sealed record DemoMembers(FamilyMember Dan, FamilyMember Eve, FamilyMember Alice, FamilyMember Bob); +} diff --git a/src/FamilyNido.Api/Bootstrap/DemoSeedOptions.cs b/src/FamilyNido.Api/Bootstrap/DemoSeedOptions.cs new file mode 100644 index 0000000..67e7a90 --- /dev/null +++ b/src/FamilyNido.Api/Bootstrap/DemoSeedOptions.cs @@ -0,0 +1,67 @@ +namespace FamilyNido.Api.Bootstrap; + +/// +/// Strongly-typed binding for the Seed:Demo configuration section. +/// Drives , which only registers in the +/// Development environment and no-ops unless 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. +/// +public sealed class DemoSeedOptions +{ + /// Configuration section name. + public const string SectionName = "Seed:Demo"; + + /// Master switch. The seeder no-ops unless this is true. + public bool Enabled { get; init; } + + /// Display name of the demo family. Idempotency key โ€” re-running with + /// the same name is a no-op once the family exists. + public string FamilyName { get; init; } = "Smith Family"; + + /// 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. + public string TimeZone { get; init; } = "Europe/Madrid"; + + /// BCP-47 locale used for date/number formatting in the UI. The + /// per-user PreferredLanguage drives the actual UI language; pick + /// en-US here if your screenshots target an English README. + public string Locale { get; init; } = "en-US"; + + /// Preferred language each demo adult ends up with. Same hint as + /// โ€” overrides at the user level. + public string PreferredLanguage { get; init; } = "en-US"; + + /// Human-readable label rendered under the weather widget. + public string LocationLabel { get; init; } = "Bilbao"; + + /// Geographic latitude in decimal degrees. Drives the weather widget. + public double Latitude { get; init; } = 43.2630; + + /// Geographic longitude in decimal degrees. + public double Longitude { get; init; } = -2.9350; + + /// Email used for the primary adult (admin). Required to be able to + /// log in and capture screenshots; the password lives in + /// . + public string AdminEmail { get; init; } = "dan@familynido.demo"; + + /// Display name of the primary adult. + public string AdminDisplayName { get; init; } = "Dan"; + + /// Local-credential password for the admin. Required when + /// is true. + public string AdminPassword { get; init; } = string.Empty; + + /// Email of the second adult. + public string PartnerEmail { get; init; } = "eve@familynido.demo"; + + /// Display name of the second adult. + public string PartnerDisplayName { get; init; } = "Eve"; + + /// Local-credential password for the second adult. Optional โ€” if + /// empty, the partner is created without local credentials (you can still + /// browse as the admin). + public string PartnerPassword { get; init; } = string.Empty; +} diff --git a/src/FamilyNido.Api/Bootstrap/E2ESeedOptions.cs b/src/FamilyNido.Api/Bootstrap/E2ESeedOptions.cs new file mode 100644 index 0000000..5d6519a --- /dev/null +++ b/src/FamilyNido.Api/Bootstrap/E2ESeedOptions.cs @@ -0,0 +1,45 @@ +namespace FamilyNido.Api.Bootstrap; + +/// +/// Strongly-typed binding for the Seed:E2E configuration section. +/// Only honored when is +/// Testing; production never reads this section. +/// +public sealed class E2ESeedOptions +{ + /// Configuration section name. + public const string SectionName = "Seed:E2E"; + + /// Master switch. The seeder no-ops unless this is true. + public bool Enabled { get; init; } + + /// Name of the test family. Used as the idempotency key. + public string FamilyName { get; init; } = "FamilyNido E2E"; + + /// IANA time zone for the seeded family. + public string TimeZone { get; init; } = "Europe/Madrid"; + + /// Email of admin tester A. Stable across runs. + public string UserAEmail { get; init; } = "e2e-a@FamilyNido.test"; + + /// Local-credential password for tester A. Required when is true. + public string UserAPassword { get; init; } = string.Empty; + + /// Display name for tester A. + public string UserADisplayName { get; init; } = "Tester A"; + + /// Hex color for tester A's avatar. + public string UserAColorHex { get; init; } = "#3B82F6"; + + /// Email of adult tester B. + public string UserBEmail { get; init; } = "e2e-b@FamilyNido.test"; + + /// Local-credential password for tester B. Required when is true. + public string UserBPassword { get; init; } = string.Empty; + + /// Display name for tester B. + public string UserBDisplayName { get; init; } = "Tester B"; + + /// Hex color for tester B's avatar. + public string UserBColorHex { get; init; } = "#EF4444"; +} diff --git a/src/FamilyNido.Api/Bootstrap/E2ETestDataSeeder.cs b/src/FamilyNido.Api/Bootstrap/E2ETestDataSeeder.cs new file mode 100644 index 0000000..f6b6a1e --- /dev/null +++ b/src/FamilyNido.Api/Bootstrap/E2ETestDataSeeder.cs @@ -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; + +/// +/// 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 +/// Environment == "Testing" in Program.cs, and the seeder +/// itself bails unless is true. +/// +/// +/// 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. +/// +public sealed class E2ETestDataSeeder : IHostedService +{ + private readonly IServiceProvider _services; + private readonly E2ESeedOptions _options; + private readonly ILogger _logger; + + /// Primary constructor. + public E2ETestDataSeeder( + IServiceProvider services, + IOptions options, + ILogger logger) + { + _services = services; + _options = options.Value; + _logger = logger; + } + + /// + 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(); + var hasher = scope.ServiceProvider.GetRequiredService>(); + + 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); + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task EnsureUserAsync( + ApplicationDbContext db, + IPasswordHasher 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); + } +} diff --git a/src/FamilyNido.Api/FamilyNido.Api.csproj b/src/FamilyNido.Api/FamilyNido.Api.csproj new file mode 100644 index 0000000..2546e00 --- /dev/null +++ b/src/FamilyNido.Api/FamilyNido.Api.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + diff --git a/src/FamilyNido.Api/Features/Auth/AuthEndpoints.cs b/src/FamilyNido.Api/Features/Auth/AuthEndpoints.cs new file mode 100644 index 0000000..7b6c21b --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/AuthEndpoints.cs @@ -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; + +/// Endpoints for the authentication dance (OIDC, local login, credentials, me). +public static class AuthEndpoints +{ + /// Rate-limit policy name applied to POST /api/auth/local/login. + public const string LocalLoginRateLimitPolicy = "local-login"; + + /// Registers the /api/auth endpoints on the given route group. + 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 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 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 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 LocalLoginAsync( + LocalLogin.Command command, + IValidator 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 SetLocalPasswordAsync( + SetLocalPassword.Command command, + IValidator 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 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 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 UpdatePreferredLanguageAsync( + UpdateMyPreferredLanguage.Command command, + IValidator 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(); + } +} diff --git a/src/FamilyNido.Api/Features/Auth/AuthenticationExtensions.cs b/src/FamilyNido.Api/Features/Auth/AuthenticationExtensions.cs new file mode 100644 index 0000000..c219128 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/AuthenticationExtensions.cs @@ -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; + +/// Composition of authentication/authorization for the API. +public static class AuthenticationExtensions +{ + /// + /// Wires the cookie session, the integration API-key handler, the FamilyNido + /// policy set, and โ€” when an OIDC is + /// configured โ€” the OpenID Connect challenge handler. The OIDC scheme is + /// registered conditionally so that a deployment using only local + /// credentials doesn't trip OpenIdConnectOptions.Validate() on every + /// request the moment the auth middleware materializes its handlers. + /// + 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( + 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(OpenIdConnectDefaults.AuthenticationScheme) + .Configure>((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(); + + 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(); + 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())); + } + } +} diff --git a/src/FamilyNido.Api/Features/Auth/CurrentUserContext.cs b/src/FamilyNido.Api/Features/Auth/CurrentUserContext.cs new file mode 100644 index 0000000..ca55312 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/CurrentUserContext.cs @@ -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; + +/// +/// Default implementation of . Caches the +/// lookup per request (it is registered scoped) so repeated calls within a +/// single HTTP request do not re-hit the database. +/// +public sealed class CurrentUserContext : ICurrentUserContext +{ + /// Custom claim type holding the internal id (Guid string). + 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; + + /// Primary constructor. + public CurrentUserContext( + IHttpContextAccessor httpContextAccessor, + ApplicationDbContext db, + TimeProvider timeProvider, + IOptions familyOptions) + { + _httpContextAccessor = httpContextAccessor; + _db = db; + _timeProvider = timeProvider; + _familyOptions = familyOptions.Value; + } + + /// + public async Task GetUserAsync(CancellationToken cancellationToken) + { + var principal = _httpContextAccessor.HttpContext?.User; + if (principal?.Identity?.IsAuthenticated != true) + { + return null; + } + + return await ResolvePrincipalUserAsync(principal, cancellationToken); + } + + /// + public async Task 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); + } + + /// + public async Task 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 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 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); + } + + /// + /// Resolves the persisted for an authenticated principal. + /// Prefers the fast userId claim baked into the cookie at login time; + /// falls back to the OIDC sub claim via + /// for legacy sessions issued before this claim was added. + /// + private async Task 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; +} diff --git a/src/FamilyNido.Api/Features/Auth/GetMe.cs b/src/FamilyNido.Api/Features/Auth/GetMe.cs new file mode 100644 index 0000000..74d2987 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/GetMe.cs @@ -0,0 +1,46 @@ +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; + +namespace FamilyNido.Api.Features.Auth; + +/// Slice for GET /api/auth/me: returns the profile of the caller. +public static class GetMe +{ + /// Query carrying no input โ€” the caller is resolved from the ambient context. + public sealed record Query : IRequest>; + + /// Builds the profile DTO from . + public sealed class Handler : IRequestHandler> + { + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ICurrentUserContext userContext) => _userContext = userContext; + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Auth/HttpContextActorProvider.cs b/src/FamilyNido.Api/Features/Auth/HttpContextActorProvider.cs new file mode 100644 index 0000000..018e1af --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/HttpContextActorProvider.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using FamilyNido.Persistence; + +namespace FamilyNido.Api.Features.Auth; + +/// +/// implementation that reads the +/// authenticated subject from the active . Falls back +/// to "system" when no request is in flight (background work, startup, +/// design-time). Registered by the API composition root. +/// +public sealed class HttpContextActorProvider : ICurrentActorProvider +{ + private readonly IHttpContextAccessor _accessor; + + /// Primary constructor. + public HttpContextActorProvider(IHttpContextAccessor accessor) => _accessor = accessor; + + /// + 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"; + } +} diff --git a/src/FamilyNido.Api/Features/Auth/ICurrentUserContext.cs b/src/FamilyNido.Api/Features/Auth/ICurrentUserContext.cs new file mode 100644 index 0000000..069d248 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/ICurrentUserContext.cs @@ -0,0 +1,61 @@ +using System.Security.Claims; +using FamilyNido.Domain.Families; +using FamilyNido.Domain.Identity; + +namespace FamilyNido.Api.Features.Auth; + +/// +/// Per-request context resolving the authenticated caller to their persisted +/// , and . +/// Handles first-login bootstrapping (RF-USR-005, RF-AUTH-003) and stamps +/// the cookie with the internal userId claim used for fast lookups. +/// +public interface ICurrentUserContext +{ + /// + /// Steady-state lookup against HttpContext.User. Never mutates the + /// database โ€” use this from endpoints and hubs. Returns null when + /// the caller is unauthenticated, the user row is gone, or the user has + /// no linked yet (pre-onboarding). + /// + Task GetAsync(CancellationToken cancellationToken); + + /// + /// Resolves only the row, even when no + /// is linked. Used by onboarding flows + /// (accept-invitation) where the caller is authenticated but still orphan. + /// Returns null when the caller is unauthenticated or the row is gone. + /// + Task GetUserAsync(CancellationToken cancellationToken); + + /// + /// Provisioning variant used inside the OIDC OnTokenValidated event + /// where HttpContext.User is still anonymous. Bootstraps the first + /// admin + family on a fresh database, creates an orphan + /// (plus its ) for new subjects on an + /// already-initialized instance, or refreshes LastLoginAt on + /// repeated logins. + /// + /// + /// The resolved identity when an OIDC subject was present (linked or + /// orphan); null when the principal carries no usable subject. + /// + Task ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken); +} + +/// +/// Resolved per-request identity used by endpoints and SignalR hubs. +/// +/// Internal user row. +/// Linked family member. +/// Owning family. +public sealed record CurrentUser(User User, FamilyMember Member, Family Family); + +/// +/// Identity resolved during the OIDC callback. and +/// may be null while the user waits to be linked. +/// +/// Internal user row (always present once resolved). +/// Linked family member, or null when the user is orphan. +/// Owning family, or null when the user is orphan. +public sealed record ResolvedIdentity(User User, FamilyMember? Member, Family? Family); diff --git a/src/FamilyNido.Api/Features/Auth/ListMyCredentials.cs b/src/FamilyNido.Api/Features/Auth/ListMyCredentials.cs new file mode 100644 index 0000000..91dba2f --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/ListMyCredentials.cs @@ -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; + +/// +/// 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. +/// +public static class ListMyCredentials +{ + /// Query โ€” no inputs. + public sealed record Query : IRequest>>; + + /// Handler. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task>> 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 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>.Success(dto); + } + + private static string? Hint(string? key) + => string.IsNullOrEmpty(key) ? null : key.Length <= 6 ? key : $"โ€ฆ{key[^6..]}"; + } +} + +/// Read-model returned by GET /api/auth/credentials. +/// Credential id. +/// Provider type. +/// UTC instant the credential was added. +/// UTC instant of last successful login through this credential. +/// Last 6 chars of the OIDC subject (for UX disambiguation). Null for Local. +public sealed record CredentialDto( + Guid Id, + IdentityProvider Provider, + DateTimeOffset CreatedAt, + DateTimeOffset? LastUsedAt, + string? ProviderKeyHint); diff --git a/src/FamilyNido.Api/Features/Auth/LocalLogin.cs b/src/FamilyNido.Api/Features/Auth/LocalLogin.cs new file mode 100644 index 0000000..5ebf419 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/LocalLogin.cs @@ -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; + +/// +/// 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). +/// +public static class LocalLogin +{ + /// Command โ€” wire payload from the front. + /// Account email (case-insensitive). + /// Plain-text password. + public sealed record Command(string Email, string Password) : IRequest>; + + /// Input validation. Mirrors what the front already checks; backend stays the source of truth. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress().MaximumLength(254); + RuleFor(x => x.Password).NotEmpty().MaximumLength(256); + } + } + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly IPasswordHasher _passwordHasher; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + IPasswordHasher passwordHasher, + IHttpContextAccessor httpContextAccessor, + TimeProvider timeProvider) + { + _db = db; + _passwordHasher = passwordHasher; + _httpContextAccessor = httpContextAccessor; + _timeProvider = timeProvider; + } + + /// + public async Task> 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 + { + 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=="; + } +} + +/// Result of POST /api/auth/local/login. +/// Internal user id (front uses this only as a sanity flag). +public sealed record LocalLoginResponse(Guid UserId); diff --git a/src/FamilyNido.Api/Features/Auth/MeDto.cs b/src/FamilyNido.Api/Features/Auth/MeDto.cs new file mode 100644 index 0000000..2788468 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/MeDto.cs @@ -0,0 +1,28 @@ +using FamilyNido.Domain.Families; + +namespace FamilyNido.Api.Features.Auth; + +/// Profile payload returned by GET /api/auth/me. +/// Internal user identifier. +/// Email associated with the account. +/// Name shown in the UI. +/// Authorization role. +/// Owning family identifier. +/// Owning family display name. +/// Linked family member id, if any. +/// Linked family member display name. +/// Linked family member color code. +/// Relative path to the member's avatar image, if any. Used by the shell to load the photo via /api/family-members/{id}/photo. +/// BCP-47 tag (e.g. es-ES, en-US) the user picked for the UI. Drives email + integration content. +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); diff --git a/src/FamilyNido.Api/Features/Auth/PasswordPolicy.cs b/src/FamilyNido.Api/Features/Auth/PasswordPolicy.cs new file mode 100644 index 0000000..2719464 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/PasswordPolicy.cs @@ -0,0 +1,32 @@ +using FluentValidation; + +namespace FamilyNido.Api.Features.Auth; + +/// +/// Centralized password complexity requirements. Plain enough to remember, +/// strict enough to keep the obvious "1234" / "password" attacks out. +/// Reused by both and the invitation +/// "accept-local" path. +/// +internal static class PasswordPolicy +{ + /// Minimum number of characters. + public const int MinLength = 8; + + /// Maximum length we ever accept (defends the hash function from absurd inputs). + public const int MaxLength = 256; + + /// + /// Rule helper used by FluentValidation builders. + /// Requires length, at least one letter, and at least one digit. + /// + public static IRuleBuilderOptions Password(this IRuleBuilder 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."); + } +} diff --git a/src/FamilyNido.Api/Features/Auth/Policies.cs b/src/FamilyNido.Api/Features/Auth/Policies.cs new file mode 100644 index 0000000..d74d495 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/Policies.cs @@ -0,0 +1,39 @@ +using FamilyNido.Api.Features.Integrations; +using FamilyNido.Domain.Families; +using Microsoft.AspNetCore.Authorization; + +namespace FamilyNido.Api.Features.Auth; + +/// +/// Named authorization policies used across endpoints. Centralizing them keeps +/// policy names discoverable and prevents magic-string drift. +/// +public static class Policies +{ + /// Admin of the family โ€” can manage members and configuration. + public const string Admin = "family.admin"; + + /// Adult โ€” default access level for authenticated users. + public const string Adult = "family.adult"; + + /// Any authenticated and linked user (includes guests). + public const string AuthenticatedUser = "family.member"; + + /// External integration callers authenticated by an API key. + public const string Integration = "family.integration"; + + /// Registers the policies above against the supplied builder. + 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; + } +} diff --git a/src/FamilyNido.Api/Features/Auth/RemoveCredential.cs b/src/FamilyNido.Api/Features/Auth/RemoveCredential.cs new file mode 100644 index 0000000..10c7aa7 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/RemoveCredential.cs @@ -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; + +/// +/// Slice: an authenticated user deletes one of their own credentials. +/// Refuses to remove the last credential to prevent self-lockout. +/// +public static class RemoveCredential +{ + /// Command. + /// Credential to remove. + public sealed record Command(Guid CredentialId) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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; + } + } +} diff --git a/src/FamilyNido.Api/Features/Auth/SetLocalPassword.cs b/src/FamilyNido.Api/Features/Auth/SetLocalPassword.cs new file mode 100644 index 0000000..c81e561 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/SetLocalPassword.cs @@ -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; + +/// +/// 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. +/// +public static class SetLocalPassword +{ + /// Command. + /// Current password (required when rotating; ignored on first-time setup). + /// New password. + public sealed record Command(string? CurrentPassword, string NewPassword) : IRequest>; + + /// Validator โ€” only checks the new password's complexity here. Current-password verification happens in the handler against the stored hash. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.NewPassword).Password(); + } + } + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly IPasswordHasher _passwordHasher; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + IPasswordHasher passwordHasher, + TimeProvider timeProvider) + { + _db = db; + _userContext = userContext; + _passwordHasher = passwordHasher; + _timeProvider = timeProvider; + } + + /// + public async Task> 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; + } + } +} diff --git a/src/FamilyNido.Api/Features/Auth/UpdateMyPreferredLanguage.cs b/src/FamilyNido.Api/Features/Auth/UpdateMyPreferredLanguage.cs new file mode 100644 index 0000000..ae61db0 --- /dev/null +++ b/src/FamilyNido.Api/Features/Auth/UpdateMyPreferredLanguage.cs @@ -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; + +/// +/// Slice for PUT /api/auth/me/preferred-language. 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. +/// +/// +/// 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. +/// +public static class UpdateMyPreferredLanguage +{ + /// Tags accepted by the validator. Keep aligned with the Angular bundles. + public static readonly string[] AllowedTags = { "es-ES", "en-US" }; + + /// Command body carrying the new tag. + /// BCP-47 tag from . + public sealed record Command(string Language) : IRequest>; + + /// Echo of the persisted value, used by the frontend to confirm. + /// The new tag stored on the user. + public sealed record Response(string Language); + + /// Validator: ensures the requested tag is one we actually support. + public sealed class Validator : AbstractValidator + { + /// Creates the validator. + public Validator() + { + RuleFor(x => x.Language) + .NotEmpty() + .Must(value => Array.IndexOf(AllowedTags, value) >= 0) + .WithMessage("Unsupported language tag."); + } + } + + /// Persists the new preferred language on the caller's User row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/CalendarEndpoints.cs b/src/FamilyNido.Api/Features/Calendar/CalendarEndpoints.cs new file mode 100644 index 0000000..696be14 --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/CalendarEndpoints.cs @@ -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; + +/// REST endpoints for the calendar mirror (RF-CAL-*). +public static class CalendarEndpoints +{ + /// Cookie that round-trips the Google OAuth state between start and callback. + public const string OAuthStateCookieName = "FamilyNido.calendar.google.oauth-state"; + + /// Path under which the state cookie is valid. + public const string OAuthCookiePath = "/api/calendar/google"; + + /// Registers /api/calendar endpoints on the given route group. + 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 ListEventsAsync( + DateTimeOffset from, + DateTimeOffset to, + Guid[]? memberIds, + IValidator 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 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 SetEventMembersAsync( + Guid eventId, + SetCalendarEventMembersBody body, + IValidator 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 UpdateCalendarAsync( + Guid id, + UpdateLinkedCalendarBody body, + IValidator 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 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 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 StartLinkAsync( + IMediator mediator, + HttpContext httpContext, + IOptions 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 HandleCallbackAsync( + string? code, + string? state, + string? error, + IMediator mediator, + HttpContext httpContext, + IOptions 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)}"; + } +} + +/// Wire-level body for PATCH /api/calendar/calendars/{id}. +/// Whether to mirror events from this calendar. +/// Optional family member to associate (null clears). +public sealed record UpdateLinkedCalendarBody(bool IsImported, Guid? FamilyMemberId); + +/// Wire-level body for PUT /api/calendar/events/{eventId}/members. +/// New full set of related members (replaces the previous one). Empty array clears all relations. +public sealed record SetCalendarEventMembersBody(IReadOnlyList? MemberIds); diff --git a/src/FamilyNido.Api/Features/Calendar/CalendarEventDto.cs b/src/FamilyNido.Api/Features/Calendar/CalendarEventDto.cs new file mode 100644 index 0000000..7631568 --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/CalendarEventDto.cs @@ -0,0 +1,47 @@ +using FamilyNido.Domain.Calendar; + +namespace FamilyNido.Api.Features.Calendar; + +/// Wire-level view of a mirrored Google Calendar event. +/// FamilyNido id of the event row. +/// Calendar of origin within the linked account. +/// Family member associated to the source calendar (if any) โ€” drives the color in the UI. +/// Event title. +/// Optional longer description. +/// Optional location string. +/// Start instant in UTC. +/// End instant in UTC. +/// True when the event has no specific time-of-day. +/// Original IANA timezone reported by Google. +/// Public link back to the event in Google Calendar. +/// Per-event N:M of family members tagged locally on this event (independent of the calendar default). +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 RelatedMemberIds) +{ + /// Builds a DTO from the persisted entity (assumes and RelatedMembers are loaded). + 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)]); +} diff --git a/src/FamilyNido.Api/Features/Calendar/CalendarSyncBackgroundService.cs b/src/FamilyNido.Api/Features/Calendar/CalendarSyncBackgroundService.cs new file mode 100644 index 0000000..2930b1c --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/CalendarSyncBackgroundService.cs @@ -0,0 +1,81 @@ +using FamilyNido.Api.Options; +using Microsoft.Extensions.Options; + +namespace FamilyNido.Api.Features.Calendar; + +/// +/// Hosted service that runs on a +/// fixed cadence (). Sleeps with a +/// so the loop wakes up promptly on shutdown. +/// +public sealed class CalendarSyncBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IOptionsMonitor _options; + private readonly ILogger _logger; + + /// Primary constructor. + public CalendarSyncBackgroundService( + IServiceScopeFactory scopeFactory, + IOptionsMonitor options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _options = options; + _logger = logger; + } + + /// + 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(); + 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."); + } + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/CalendarSynchronizer.cs b/src/FamilyNido.Api/Features/Calendar/CalendarSynchronizer.cs new file mode 100644 index 0000000..876a272 --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/CalendarSynchronizer.cs @@ -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; + +/// +/// Synchronization engine for the calendar mirror. Talks to Google through +/// , upserts into , +/// and persists the nextSyncToken so subsequent runs do an incremental delta. +/// Reused by both the periodic background service and the manual-sync endpoint. +/// +public sealed class CalendarSynchronizer +{ + private readonly ApplicationDbContext _db; + private readonly GoogleOAuthService _oauth; + private readonly GoogleCalendarClient _client; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + + /// Primary constructor. + public CalendarSynchronizer( + ApplicationDbContext db, + GoogleOAuthService oauth, + GoogleCalendarClient client, + TimeProvider clock, + ILogger logger) + { + _db = db; + _oauth = oauth; + _client = client; + _clock = clock; + _logger = logger; + } + + /// + /// Runs a sync for every imported calendar of . Used + /// by the manual-sync endpoint to trigger an account-scoped refresh on demand. + /// + 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); + } + + /// + /// 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. + /// + 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 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 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; + } + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/GoogleAccountDto.cs b/src/FamilyNido.Api/Features/Calendar/GoogleAccountDto.cs new file mode 100644 index 0000000..9a17573 --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/GoogleAccountDto.cs @@ -0,0 +1,66 @@ +using FamilyNido.Domain.Calendar; + +namespace FamilyNido.Api.Features.Calendar; + +/// Wire-level view of a linked Google account plus its discovered calendars. +/// FamilyNido id of the account row. +/// Google email โ€” labels the account in the UI. +/// Best-effort display name reported by Google. +/// True when the refresh token was rejected; the user must re-link. +/// Last sync error message captured for diagnostics; null when healthy. +/// UTC instant the account was linked. +/// Calendars discovered under this account. +public sealed record GoogleAccountDto( + Guid Id, + string Email, + string? DisplayName, + bool IsRevoked, + string? LastError, + DateTimeOffset LinkedAt, + IReadOnlyList Calendars) +{ + /// Builds a DTO from the persisted entity (with eager-loaded calendars). + 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)]); +} + +/// Wire-level view of a single Google calendar discovered under an account. +/// FamilyNido id of the linked-calendar row. +/// Google's calendar id (stable across accounts). +/// Display name from Google. +/// Optional description from Google. +/// Color reported by Google (#RRGGBB). +/// Whether events from this calendar are mirrored. +/// Optional family member assigned for color attribution. +/// UTC instant of the last successful sync; null until first sync. +public sealed record LinkedCalendarDto( + Guid Id, + string ExternalCalendarId, + string Summary, + string? Description, + string? ColorHex, + bool IsImported, + Guid? FamilyMemberId, + DateTimeOffset? LastSyncedAt) +{ + /// Builds a DTO from the persisted entity. + public static LinkedCalendarDto From(LinkedCalendar calendar) + => new( + calendar.Id, + calendar.ExternalCalendarId, + calendar.Summary, + calendar.Description, + calendar.ColorHex, + calendar.IsImported, + calendar.FamilyMemberId, + calendar.LastSyncedAt); +} diff --git a/src/FamilyNido.Api/Features/Calendar/GoogleCalendarClient.cs b/src/FamilyNido.Api/Features/Calendar/GoogleCalendarClient.cs new file mode 100644 index 0000000..28b68bf --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/GoogleCalendarClient.cs @@ -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; + +/// +/// Thin wrapper around the official Google.Apis.Calendar.v3 SDK that hides +/// the credential plumbing. Each call accepts a refresh token and builds a fresh +/// in-memory โ€” no on-disk token cache, no global state. +/// +public sealed class GoogleCalendarClient +{ + private readonly IOptions _options; + + /// Primary constructor. + public GoogleCalendarClient(IOptions options) + { + _options = options; + } + + /// Lists every calendar visible to the linked Google account. + /// Plaintext refresh token of the linked account. + /// Cancellation propagated from the caller. + /// Calendars as Google reports them โ€” caller decides which to mirror. + public async Task> 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(); + 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; + } + + /// + /// Pages through events.list for a single calendar. When + /// is non-null, performs an incremental sync; when + /// null, performs a full sync within the configured lookback/lookahead window. + /// + /// Plaintext refresh token of the linked account. + /// Google calendar id to enumerate. + /// Latest nextSyncToken received, or null for a full sync. + /// Cancellation propagated from the caller. + public async Task ListEventsAsync( + string refreshToken, + string externalCalendarId, + string? syncToken, + CancellationToken cancellationToken) + { + using var service = BuildService(refreshToken); + + var aggregated = new List(); + 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", + }); + } + + /// + /// 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. + /// + private sealed class NullDataStore : IDataStore + { + public Task ClearAsync() => Task.CompletedTask; + public Task DeleteAsync(string key) => Task.CompletedTask; + public Task GetAsync(string key) => Task.FromResult(default!); + public Task StoreAsync(string key, T value) => Task.CompletedTask; + } +} + +/// +/// Result of a events.list page traversal. +/// +/// All events from every page returned by Google. +/// Token to persist for the next incremental sync; null when a full sync is still pending. +/// True when Google replied 410 Gone โ€” the caller must reset its stored sync token and run a full sync next. +public sealed record GoogleEventsPage( + IReadOnlyList Events, + string? NextSyncToken, + bool FullSyncRequired); diff --git a/src/FamilyNido.Api/Features/Calendar/GoogleOAuthService.cs b/src/FamilyNido.Api/Features/Calendar/GoogleOAuthService.cs new file mode 100644 index 0000000..33a2e3b --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/GoogleOAuthService.cs @@ -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; + +/// +/// 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. +/// +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 _options; + private readonly TimeProvider _clock; + private readonly IHttpClientFactory _httpClientFactory; + + /// Primary constructor. + public GoogleOAuthService( + IDataProtectionProvider dataProtectionProvider, + IOptions 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; + } + + /// + /// 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 state query parameter; the encrypted blob lives in a short-lived + /// cookie and is re-validated on the callback. + /// + /// Authenticated FamilyNido user id. + /// Tuple containing the auth URL, the encrypted cookie payload, and the cookie expiration. + 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 + { + ["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); + } + + /// + /// Validates the encrypted state cookie content against the state query + /// parameter Google echoed back. Returns null when the cookie is missing, expired, + /// tampered with, or does not match the query nonce. + /// + 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(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; + } + } + + /// + /// Exchanges the authorization 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. + /// + public async Task ExchangeCodeAsync(string code, CancellationToken cancellationToken) + { + var options = _options.Value; + using var http = _httpClientFactory.CreateClient(nameof(GoogleOAuthService)); + + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("code", code), + new KeyValuePair("client_id", options.GoogleClientId), + new KeyValuePair("client_secret", options.GoogleClientSecret), + new KeyValuePair("redirect_uri", options.OAuthRedirectUri), + new KeyValuePair("grant_type", "authorization_code"), + }); + + using var response = await http.PostAsync(TokenEndpoint, form, cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (payload is null || string.IsNullOrEmpty(payload.AccessToken)) + { + throw new InvalidOperationException("Google token endpoint returned an empty response."); + } + + return payload; + } + + /// + /// 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 GoogleAccount.IsRevoked instead of looping forever. + /// + public async Task RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken) + { + var options = _options.Value; + using var http = _httpClientFactory.CreateClient(nameof(GoogleOAuthService)); + + var form = new FormUrlEncodedContent(new[] + { + new KeyValuePair("refresh_token", refreshToken), + new KeyValuePair("client_id", options.GoogleClientId), + new KeyValuePair("client_secret", options.GoogleClientSecret), + new KeyValuePair("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(cancellationToken: cancellationToken); + } + + /// Encrypts a refresh token for storage in google_accounts.encrypted_refresh_token. + public string ProtectRefreshToken(string plaintext) => _refreshTokenProtector.Protect(plaintext); + + /// Decrypts a refresh token. Throws if the ciphertext was tampered with or the key was rotated past retention. + public string UnprotectRefreshToken(string ciphertext) => _refreshTokenProtector.Unprotect(ciphertext); + + /// + /// 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. + /// + 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 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); + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/GoogleOAuthState.cs b/src/FamilyNido.Api/Features/Calendar/GoogleOAuthState.cs new file mode 100644 index 0000000..edd133a --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/GoogleOAuthState.cs @@ -0,0 +1,12 @@ +namespace FamilyNido.Api.Features.Calendar; + +/// +/// 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. +/// +/// FamilyNido user id that initiated the link. +/// Random value also passed to Google as the state query parameter โ€” must round-trip exactly. +/// Hard expiration; callbacks beyond this are rejected. +public sealed record GoogleOAuthState(Guid UserId, string Nonce, DateTimeOffset ExpiresAt); diff --git a/src/FamilyNido.Api/Features/Calendar/GoogleTokenResponse.cs b/src/FamilyNido.Api/Features/Calendar/GoogleTokenResponse.cs new file mode 100644 index 0000000..5be2af0 --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/GoogleTokenResponse.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace FamilyNido.Api.Features.Calendar; + +/// +/// Token endpoint response from https://oauth2.googleapis.com/token. Only +/// the fields we actually consume are surfaced. +/// +public sealed class GoogleTokenResponse +{ + /// Short-lived OAuth access token (typically 1 hour). + [JsonPropertyName("access_token")] + public string? AccessToken { get; init; } + + /// + /// Long-lived refresh token. Only returned on the first consent (or when + /// prompt=consent forces re-issuance). Persisted encrypted. + /// + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; init; } + + /// Identity token containing the verified email and display name. + [JsonPropertyName("id_token")] + public string? IdToken { get; init; } + + /// Token lifetime in seconds. + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; init; } + + /// Granted scope (space-separated). + [JsonPropertyName("scope")] + public string? Scope { get; init; } +} diff --git a/src/FamilyNido.Api/Features/Calendar/HandleGoogleCallback.cs b/src/FamilyNido.Api/Features/Calendar/HandleGoogleCallback.cs new file mode 100644 index 0000000..5e485f9 --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/HandleGoogleCallback.cs @@ -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; + +/// +/// Slice: process the Google OAuth callback. Validates the state cookie, exchanges +/// the authorization code for tokens, persists (or refreshes) the +/// , and discovers the visible calendars so the user can +/// choose which to import on the cuentas screen. +/// +public static class HandleGoogleCallback +{ + /// Inputs harvested from the callback request. + /// Authorization code returned by Google. + /// State value Google echoed back via the state query parameter. + /// Encrypted payload from the OAuth state cookie. + public sealed record Command( + string Code, + string? QueryStateNonce, + string? EncryptedStateCookie) : IRequest>; + + /// Outcome surfaced to the redirect handler. + /// Id of the persisted account row. + /// How many calendars were discovered (purely informational). + public sealed record Response(Guid GoogleAccountId, int DiscoveredCalendars); + + /// Handler โ€” runs the OAuth post-back logic. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly GoogleOAuthService _oauth; + private readonly GoogleCalendarClient _calendarClient; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + GoogleOAuthService oauth, + GoogleCalendarClient calendarClient) + { + _db = db; + _oauth = oauth; + _calendarClient = calendarClient; + } + + /// + public async Task> 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 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/ListEvents.cs b/src/FamilyNido.Api/Features/Calendar/ListEvents.cs new file mode 100644 index 0000000..6854789 --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/ListEvents.cs @@ -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; + +/// +/// Slice: read events for the family calendar within a time window. Filters cover +/// the day/week/month/agenda views by adjusting from/to on the client side. +/// +public static class ListEvents +{ + /// Query โ€” half-open time window, optional member filter. + /// Inclusive lower bound (UTC). + /// Exclusive upper bound (UTC). + /// Optional set of family member ids; null/empty means "all members". + public sealed record Query( + DateTimeOffset From, + DateTimeOffset To, + IReadOnlyList? MemberIds) : IRequest>>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator. + 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."); + } + } + + /// Handler โ€” runs the family-scoped range query. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task>> 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 dto = [.. rows.Select(CalendarEventDto.From)]; + return Result>.Success(dto); + } + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/ListLinkedAccounts.cs b/src/FamilyNido.Api/Features/Calendar/ListLinkedAccounts.cs new file mode 100644 index 0000000..092cf21 --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/ListLinkedAccounts.cs @@ -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; + +/// Slice: list every Google account linked under the current family. +public static class ListLinkedAccounts +{ + /// Empty query. + public sealed record Query() : IRequest>>; + + /// Handler โ€” returns all family accounts (every adult sees the family-wide picture). + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task>> 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 dto = [.. accounts.Select(GoogleAccountDto.From)]; + return Result>.Success(dto); + } + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/SetCalendarEventMembers.cs b/src/FamilyNido.Api/Features/Calendar/SetCalendarEventMembers.cs new file mode 100644 index 0000000..983f34b --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/SetCalendarEventMembers.cs @@ -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; + +/// +/// 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). +/// +public static class SetCalendarEventMembers +{ + /// Command. + /// Local event id (FamilyNido CalendarEvent.Id). + /// New set of related members. Empty clears the relation. + public sealed record Command(Guid EventId, IReadOnlyList MemberIds) + : IRequest>; + + /// Validator. + public sealed class Validator : AbstractValidator + { + /// Creates the validator. + public Validator() + { + RuleFor(x => x.EventId).NotEmpty(); + RuleFor(x => x.MemberIds).NotNull(); + } + } + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/StartGoogleLink.cs b/src/FamilyNido.Api/Features/Calendar/StartGoogleLink.cs new file mode 100644 index 0000000..e472b4a --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/StartGoogleLink.cs @@ -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; + +/// +/// 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. +/// +public static class StartGoogleLink +{ + /// Empty command โ€” the caller is identified via . + public sealed record Command() : IRequest>; + + /// Response returned to the frontend. + /// Full Google authorization URL the user has to follow. + /// Encrypted state to set in the OAuth state cookie. + /// UTC instant the state expires (cookie max-age cap). + public sealed record Response(string AuthUrl, string EncryptedState, DateTimeOffset ExpiresAt); + + /// Handler โ€” composes the auth URL via . + public sealed class Handler : IRequestHandler> + { + private readonly ICurrentUserContext _userContext; + private readonly GoogleOAuthService _oauth; + + /// Primary constructor. + public Handler(ICurrentUserContext userContext, GoogleOAuthService oauth) + { + _userContext = userContext; + _oauth = oauth; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/TriggerManualSync.cs b/src/FamilyNido.Api/Features/Calendar/TriggerManualSync.cs new file mode 100644 index 0000000..8371558 --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/TriggerManualSync.cs @@ -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; + +/// Slice: trigger an inline sync of a single Google account on demand (RF-CAL-005). +public static class TriggerManualSync +{ + /// Command โ€” identifies the account to sync. + /// Id of the account to sync. + public sealed record Command(Guid GoogleAccountId) : IRequest>; + + /// Handler โ€” runs the synchronizer, then returns the updated DTO. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly CalendarSynchronizer _synchronizer; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + CalendarSynchronizer synchronizer) + { + _db = db; + _userContext = userContext; + _synchronizer = synchronizer; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/UnlinkGoogleAccount.cs b/src/FamilyNido.Api/Features/Calendar/UnlinkGoogleAccount.cs new file mode 100644 index 0000000..165c2fc --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/UnlinkGoogleAccount.cs @@ -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; + +/// +/// 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. +/// +public static class UnlinkGoogleAccount +{ + /// Command โ€” identifies the account to unlink. + /// Id of the account row. + public sealed record Command(Guid GoogleAccountId) : IRequest>; + + /// Carries no value; signals "completed" to the endpoint. + public sealed record Unit; + + /// Handler โ€” deletes the row; cascade removes calendars and events. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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(); + } + } +} diff --git a/src/FamilyNido.Api/Features/Calendar/UpdateLinkedCalendar.cs b/src/FamilyNido.Api/Features/Calendar/UpdateLinkedCalendar.cs new file mode 100644 index 0000000..f2fdbc6 --- /dev/null +++ b/src/FamilyNido.Api/Features/Calendar/UpdateLinkedCalendar.cs @@ -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; + +/// +/// 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. +/// +public static class UpdateLinkedCalendar +{ + /// Command โ€” partial update of a linked calendar. + /// Id of the linked calendar to update. + /// New value for the import flag. + /// Optional family member to associate (null clears the assignment). + public sealed record Command( + Guid LinkedCalendarId, + bool IsImported, + Guid? FamilyMemberId) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator. + public Validator() + { + RuleFor(x => x.LinkedCalendarId).NotEmpty(); + } + } + + /// Handler โ€” applies the patch and saves. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Dashboard/DashboardEndpoints.cs b/src/FamilyNido.Api/Features/Dashboard/DashboardEndpoints.cs new file mode 100644 index 0000000..2ccbde6 --- /dev/null +++ b/src/FamilyNido.Api/Features/Dashboard/DashboardEndpoints.cs @@ -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; + +/// Endpoints exposing the dashboard widget layout. +public static class DashboardEndpoints +{ + /// Registers GET/PUT /api/dashboard/preferences. + 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 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 UpdatePreferencesAsync( + UpdateMyDashboardPreferences.Command command, + IValidator 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(); + } +} diff --git a/src/FamilyNido.Api/Features/Dashboard/DashboardPreferencesDto.cs b/src/FamilyNido.Api/Features/Dashboard/DashboardPreferencesDto.cs new file mode 100644 index 0000000..9558dc6 --- /dev/null +++ b/src/FamilyNido.Api/Features/Dashboard/DashboardPreferencesDto.cs @@ -0,0 +1,13 @@ +namespace FamilyNido.Api.Features.Dashboard; + +/// One widget entry in the user's dashboard layout. +/// Stable widget identifier (see ). +/// True when the widget is rendered on the dashboard. +public sealed record DashboardWidgetDto(string Id, bool Visible); + +/// +/// Wire shape returned by GET /api/dashboard/preferences: 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. +/// +public sealed record DashboardPreferencesDto(IReadOnlyList Widgets); diff --git a/src/FamilyNido.Api/Features/Dashboard/DashboardWidgets.cs b/src/FamilyNido.Api/Features/Dashboard/DashboardWidgets.cs new file mode 100644 index 0000000..ef4d661 --- /dev/null +++ b/src/FamilyNido.Api/Features/Dashboard/DashboardWidgets.cs @@ -0,0 +1,51 @@ +namespace FamilyNido.Api.Features.Dashboard; + +/// +/// 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. +/// +/// +/// Adding a widget is a three-step dance: extend this list (and document the +/// label), include it in , and render it in the +/// dashboard component. New widgets default to visible at the bottom for +/// existing users. +/// +public static class DashboardWidgets +{ + /// Open-Meteo current-weather card. + public const string Weather = "weather"; + + /// Today's bus / extras / drop-off + pick-up summary. + public const string School = "school"; + + /// Pending household tasks for today. + public const string Tasks = "tasks"; + + /// Top of the upcoming Google Calendar events list. + public const string Calendar = "calendar"; + + /// Today's and tomorrow's planned meals. + public const string Meals = "meals"; + + /// Pinned wall messages. + public const string Wall = "wall"; + + /// Birthdays in the next 30 days. + public const string Birthdays = "birthdays"; + + /// Today's agenda โ€” who is away from home and what for. + public const string Agenda = "agenda"; + + /// Family scoreboard โ€” points earned this week from completed tasks. + public const string Scores = "scores"; + + /// Default order applied when a user has no row yet. + public static IReadOnlyList DefaultOrder => new[] + { + Weather, School, Agenda, Tasks, Calendar, Meals, Wall, Scores, Birthdays, + }; + + /// True when is a known widget id. + public static bool IsKnown(string id) => DefaultOrder.Contains(id); +} diff --git a/src/FamilyNido.Api/Features/Dashboard/GetMyDashboardPreferences.cs b/src/FamilyNido.Api/Features/Dashboard/GetMyDashboardPreferences.cs new file mode 100644 index 0000000..c2959e3 --- /dev/null +++ b/src/FamilyNido.Api/Features/Dashboard/GetMyDashboardPreferences.cs @@ -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; + +/// +/// Slice for GET /api/dashboard/preferences. Reconciles the persisted +/// JSON with the catalogue of known widgets so: +/// +/// Widgets the user explicitly hid stay hidden. +/// Widgets removed from the catalogue silently disappear. +/// New widgets added to the catalogue land at the end, visible by default. +/// +/// The result is the layout the frontend renders straight away. +/// +public static class GetMyDashboardPreferences +{ + /// Query carries no payload โ€” the caller is the implicit subject. + public sealed record Query : IRequest>; + + /// Reads + reconciles the row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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)); + } + + /// + /// 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 Visible=true. + /// + internal static IReadOnlyList Reconcile(string? widgetsJson) + { + var stored = ParseStored(widgetsJson); + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + + 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 ParseStored(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try + { + var parsed = JsonSerializer.Deserialize>( + json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return parsed ?? []; + } + catch (JsonException) + { + // Corrupted column โ€” treat as defaults rather than failing the whole API. + return []; + } + } + } +} diff --git a/src/FamilyNido.Api/Features/Dashboard/UpdateMyDashboardPreferences.cs b/src/FamilyNido.Api/Features/Dashboard/UpdateMyDashboardPreferences.cs new file mode 100644 index 0000000..b793712 --- /dev/null +++ b/src/FamilyNido.Api/Features/Dashboard/UpdateMyDashboardPreferences.cs @@ -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; + +/// +/// Slice for PUT /api/dashboard/preferences. 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. +/// +public static class UpdateMyDashboardPreferences +{ + /// Command carrying the full ordered widget list. + public sealed record Command(IReadOnlyList Widgets) + : IRequest>; + + /// Validation: every id must be from the catalogue and unique. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + 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."); + } + } + + /// Performs the upsert. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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)); + } + } +} diff --git a/src/FamilyNido.Api/Features/Families/FamilyDto.cs b/src/FamilyNido.Api/Features/Families/FamilyDto.cs new file mode 100644 index 0000000..9facb3d --- /dev/null +++ b/src/FamilyNido.Api/Features/Families/FamilyDto.cs @@ -0,0 +1,18 @@ +namespace FamilyNido.Api.Features.Families; + +/// Wire shape returned by the family settings endpoint. +/// Family id. +/// Display name shown in the header. +/// IANA timezone (e.g. "Europe/Madrid"). +/// BCP-47 locale used for default formatting. +/// Geographic latitude in decimal degrees, or null when not configured. +/// Geographic longitude paired with latitude. +/// Human-readable location label (city/town/village). +public sealed record FamilyDto( + Guid Id, + string Name, + string TimeZone, + string Locale, + double? Latitude, + double? Longitude, + string? LocationLabel); diff --git a/src/FamilyNido.Api/Features/Families/FamilyEndpoints.cs b/src/FamilyNido.Api/Features/Families/FamilyEndpoints.cs new file mode 100644 index 0000000..a664b30 --- /dev/null +++ b/src/FamilyNido.Api/Features/Families/FamilyEndpoints.cs @@ -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; + +/// Endpoints exposing the family profile (read for everyone, mutate for admins). +public static class FamilyEndpoints +{ + /// Registers GET /api/family and PUT /api/family/location. + 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 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 UpdateLocationAsync( + UpdateFamilyLocation.Command command, + IValidator 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(); + } +} diff --git a/src/FamilyNido.Api/Features/Families/GetMyFamily.cs b/src/FamilyNido.Api/Features/Families/GetMyFamily.cs new file mode 100644 index 0000000..a65390a --- /dev/null +++ b/src/FamilyNido.Api/Features/Families/GetMyFamily.cs @@ -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; + +/// +/// Slice for GET /api/family. Returns the caller's family profile โ€” +/// any authenticated member of the family can read it. +/// +public static class GetMyFamily +{ + /// Query carries no payload; the caller resolves the family. + public sealed record Query : IRequest>; + + /// Resolves the family through . + public sealed class Handler : IRequestHandler> + { + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ICurrentUserContext userContext) => _userContext = userContext; + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Families/UpdateFamilyLocation.cs b/src/FamilyNido.Api/Features/Families/UpdateFamilyLocation.cs new file mode 100644 index 0000000..a7b90a0 --- /dev/null +++ b/src/FamilyNido.Api/Features/Families/UpdateFamilyLocation.cs @@ -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; + +/// +/// Slice for PUT /api/family/location. Lets a family admin set or +/// clear the family's geographic location. Drives the dashboard weather +/// widget โ€” without coordinates, the widget hides itself. +/// +public static class UpdateFamilyLocation +{ + /// Command carrying the new location, or all nulls to clear it. + /// Decimal degrees in [-90, 90], or null to clear. + /// Decimal degrees in [-180, 180], or null to clear. + /// Optional human-readable label (city/town/village). + public sealed record Command( + double? Latitude, + double? Longitude, + string? LocationLabel) : IRequest>; + + /// Validation: lat/lon must be both null or both within range. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + 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); + } + } + + /// Persists the new location on the family row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/FamilyMembers/ActivateFamilyMember.cs b/src/FamilyNido.Api/Features/FamilyMembers/ActivateFamilyMember.cs new file mode 100644 index 0000000..c22a153 --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/ActivateFamilyMember.cs @@ -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; + +/// Slice: re-activate a previously archived member (RF-USR-004). +public static class ActivateFamilyMember +{ + /// Command carrying the target id. + public sealed record Command(Guid MemberId) : IRequest>; + + /// Handler โ€” flips IsActive to true. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/FamilyMembers/CreateFamilyMember.cs b/src/FamilyNido.Api/Features/FamilyMembers/CreateFamilyMember.cs new file mode 100644 index 0000000..86284c8 --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/CreateFamilyMember.cs @@ -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; + +/// Slice: create a new under the caller's family (RF-USR-002). +public static partial class CreateFamilyMember +{ + [GeneratedRegex("^#[0-9a-fA-F]{6}$", RegexOptions.Compiled)] + private static partial Regex HexColorRegex(); + + /// Command carrying the input payload. + /// Name shown in the UI. 1-120 chars. + /// Kind of member. + /// Hex color (#RRGGBB) to use consistently across the app. + /// Optional date of birth. + /// Optional informational contact email. + public sealed record Command( + string DisplayName, + MemberType MemberType, + string ColorHex, + DateOnly? BirthDate, + string? ContactEmail) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + 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."); + } + } + + /// Handler โ€” persists a new family member row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/FamilyMembers/DeactivateFamilyMember.cs b/src/FamilyNido.Api/Features/FamilyMembers/DeactivateFamilyMember.cs new file mode 100644 index 0000000..80593a7 --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/DeactivateFamilyMember.cs @@ -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; + +/// Slice: soft-deactivate a member while preserving history (RF-USR-004). +public static class DeactivateFamilyMember +{ + /// Command carrying the target id. + public sealed record Command(Guid MemberId) : IRequest>; + + /// Handler โ€” enforces the at-least-one-admin invariant. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } + + /// + /// True when is an active admin AND there are no + /// other active admins in the family. Shared with . + /// + internal static async Task 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; + } +} diff --git a/src/FamilyNido.Api/Features/FamilyMembers/DeleteFamilyMember.cs b/src/FamilyNido.Api/Features/FamilyMembers/DeleteFamilyMember.cs new file mode 100644 index 0000000..489748c --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/DeleteFamilyMember.cs @@ -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; + +/// Slice: hard-delete a member, preserving the at-least-one-admin invariant (RF-USR-004). +public static class DeleteFamilyMember +{ + /// Command carrying the target id. + public sealed record Command(Guid MemberId) : IRequest>; + + /// Handler โ€” refuses if the target is the last admin. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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; + } + } +} diff --git a/src/FamilyNido.Api/Features/FamilyMembers/FamilyMemberDto.cs b/src/FamilyNido.Api/Features/FamilyMembers/FamilyMemberDto.cs new file mode 100644 index 0000000..8375bad --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/FamilyMemberDto.cs @@ -0,0 +1,47 @@ +using FamilyNido.Api.Features.Invitations; +using FamilyNido.Domain.Families; +using FamilyNido.Domain.Identity; + +namespace FamilyNido.Api.Features.FamilyMembers; + +/// +/// Read-model projection of a returned by the API. +/// +/// Stable member identifier. +/// Name shown across the UI. +/// Kind of member (adult/child/other). +/// Consistent color for this member in calendar and avatars. +/// Date of birth if known. +/// Informational contact email, not the OIDC email. +/// Relative path to the avatar file, if any. +/// Whether the member is active or archived. +/// True when a user account is linked. +/// Authorization role if the member has an account, else null. +/// Pending (non-consumed, non-revoked, non-expired) invitation, if any. +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) +{ + /// Project a domain entity to the DTO shape used by the API. + 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)); +} diff --git a/src/FamilyNido.Api/Features/FamilyMembers/FamilyMemberEndpoints.cs b/src/FamilyNido.Api/Features/FamilyMembers/FamilyMemberEndpoints.cs new file mode 100644 index 0000000..88d83d2 --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/FamilyMemberEndpoints.cs @@ -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; + +/// REST endpoints for managing family members (RF-USR-*). +public static class FamilyMemberEndpoints +{ + /// Registers /api/family-members endpoints on the given route group. + 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 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 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 CreateAsync( + CreateFamilyMember.Command command, + IValidator 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 UpdateAsync( + Guid id, + UpdateFamilyMemberBody body, + IValidator 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 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 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 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 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 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 DownloadPhotoAsync( + Guid id, + IMediator mediator, + IOptions 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); + } +} + +/// +/// Wire-level payload for PUT /api/family-members/{id}. The route parameter +/// carries the target id, so the body only carries the mutable fields. +/// +/// Name shown in the UI. 1-120 chars. +/// Hex color (#RRGGBB). +/// Optional date of birth. +/// Optional informational contact email. +public sealed record UpdateFamilyMemberBody( + string DisplayName, + string ColorHex, + DateOnly? BirthDate, + string? ContactEmail); diff --git a/src/FamilyNido.Api/Features/FamilyMembers/GetFamilyMember.cs b/src/FamilyNido.Api/Features/FamilyMembers/GetFamilyMember.cs new file mode 100644 index 0000000..6acfbdb --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/GetFamilyMember.cs @@ -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; + +/// Fetches a single member of the caller's family by id (RF-USR-001). +public static class GetFamilyMember +{ + /// Query carrying the target member id. + public sealed record Query(Guid MemberId) : IRequest>; + + /// Handler. Resolves the calling family before querying. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/FamilyMembers/ListFamilyMembers.cs b/src/FamilyNido.Api/Features/FamilyMembers/ListFamilyMembers.cs new file mode 100644 index 0000000..d3c23fe --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/ListFamilyMembers.cs @@ -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; + +/// Lists members of the current user's family (RF-USR-001). +public static class ListFamilyMembers +{ + /// Query carrying the optional "include archived" flag. + /// When true, archived rows are also returned. + public sealed record Query(bool IncludeInactive) : IRequest>>; + + /// EF Core-backed handler. Requires the caller to be linked. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider timeProvider) + { + _db = db; + _userContext = userContext; + _timeProvider = timeProvider; + } + + /// + public async Task>> 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 dto = [.. rows.Select(r => FamilyMemberDto.From(r.Member, r.Pending))]; + return Result>.Success(dto); + } + } +} diff --git a/src/FamilyNido.Api/Features/FamilyMembers/RemoveMemberPhoto.cs b/src/FamilyNido.Api/Features/FamilyMembers/RemoveMemberPhoto.cs new file mode 100644 index 0000000..e18d776 --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/RemoveMemberPhoto.cs @@ -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; + +/// +/// Slice: clears a member's photo. Mirrors the auth rules of +/// (admin OR self) and removes both the row +/// pointer (PhotoPath) and the underlying file on disk so the avatar +/// pipeline falls back to initials. +/// +public static class RemoveMemberPhoto +{ + /// Command. + /// Target member id. + public sealed record Command(Guid MemberId) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly FilesOptions _filesOptions; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + IOptions filesOptions) + { + _db = db; + _userContext = userContext; + _filesOptions = filesOptions.Value; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/FamilyMembers/UpdateFamilyMember.cs b/src/FamilyNido.Api/Features/FamilyMembers/UpdateFamilyMember.cs new file mode 100644 index 0000000..0baa29b --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/UpdateFamilyMember.cs @@ -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; + +/// Slice: update mutable fields of an existing member (RF-USR-003). +public static partial class UpdateFamilyMember +{ + [GeneratedRegex("^#[0-9a-fA-F]{6}$", RegexOptions.Compiled)] + private static partial Regex HexColorRegex(); + + /// Command carrying the target id and the new values. + /// Target member id. + /// Name shown in the UI. 1-120 chars. + /// Hex color (#RRGGBB). + /// Optional date of birth. + /// Optional informational contact email. + public sealed record Command( + Guid MemberId, + string DisplayName, + string ColorHex, + DateOnly? BirthDate, + string? ContactEmail) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + 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."); + } + } + + /// Handler โ€” writes the mutable fields to the tracked entity. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/FamilyMembers/UploadMemberPhoto.cs b/src/FamilyNido.Api/Features/FamilyMembers/UploadMemberPhoto.cs new file mode 100644 index 0000000..724d08b --- /dev/null +++ b/src/FamilyNido.Api/Features/FamilyMembers/UploadMemberPhoto.cs @@ -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; + +/// +/// Slice: a member's profile photo. The endpoint accepts any of the +/// images allowed by , 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. +/// +public static class UploadMemberPhoto +{ + /// Final stored size for every avatar (square). + public const int OutputSize = 512; + + /// JPEG quality for the re-encoded avatar. + private const int JpegQuality = 88; + + /// Subfolder under the files volume where avatars live. + private const string AvatarsFolder = "members"; + + /// Command. + /// Target member id. + /// Input image stream (raw bytes from multipart). + /// Reported MIME type from the multipart part. + /// Reported byte count from the multipart part. + public sealed record Command( + Guid MemberId, + Stream Stream, + string ContentType, + long LengthBytes) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly FilesOptions _filesOptions; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + IOptions filesOptions) + { + _db = db; + _userContext = userContext; + _filesOptions = filesOptions.Value; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Files/DownloadFile.cs b/src/FamilyNido.Api/Features/Files/DownloadFile.cs new file mode 100644 index 0000000..a9553e1 --- /dev/null +++ b/src/FamilyNido.Api/Features/Files/DownloadFile.cs @@ -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; + +/// +/// Slice: open the bytes of a 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"). +/// +public static class DownloadFile +{ + /// Resolved payload delivered to the endpoint to stream back. + /// Open readable stream; disposed by the endpoint after sending. + /// MIME type to set on the response. + /// Size of the stream in bytes. + public sealed record FilePayload(Stream Stream, string ContentType, long SizeBytes); + + /// Query carrying the target id. + public sealed record Query(Guid FileId) : IRequest>; + + /// Handler โ€” looks up the asset and opens a read stream on disk. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly FilesOptions _options; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + IOptions options) + { + _db = db; + _userContext = userContext; + _options = options.Value; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Files/FileAssetDto.cs b/src/FamilyNido.Api/Features/Files/FileAssetDto.cs new file mode 100644 index 0000000..025cbdd --- /dev/null +++ b/src/FamilyNido.Api/Features/Files/FileAssetDto.cs @@ -0,0 +1,31 @@ +using FamilyNido.Domain.Files; + +namespace FamilyNido.Api.Features.Files; + +/// Read-model projection of a returned by the API. +/// Stable asset identifier. +/// MIME type of the stored bytes. +/// Size of the stored bytes. +/// Image width in pixels, if known. +/// Image height in pixels, if known. +/// Member who uploaded the file. +/// Relative URL the Angular client uses to load the bytes. +public sealed record FileAssetDto( + Guid Id, + string ContentType, + long SizeBytes, + int? Width, + int? Height, + Guid OwnerMemberId, + string Url) +{ + /// Project a domain entity to the DTO shape used by the API. + 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}"); +} diff --git a/src/FamilyNido.Api/Features/Files/FileEndpoints.cs b/src/FamilyNido.Api/Features/Files/FileEndpoints.cs new file mode 100644 index 0000000..a38518a --- /dev/null +++ b/src/FamilyNido.Api/Features/Files/FileEndpoints.cs @@ -0,0 +1,74 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; + +namespace FamilyNido.Api.Features.Files; + +/// REST endpoints for the shared file-asset module. +public static class FileEndpoints +{ + /// Registers /api/files endpoints on the given route group. + 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 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 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); + } +} diff --git a/src/FamilyNido.Api/Features/Files/UploadFile.cs b/src/FamilyNido.Api/Features/Files/UploadFile.cs new file mode 100644 index 0000000..83bca9b --- /dev/null +++ b/src/FamilyNido.Api/Features/Files/UploadFile.cs @@ -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; + +/// +/// Slice: persist an uploaded image to the configured storage root + create a +/// metadata row. Files are scoped per family (used later +/// to authorize downloads) and placed under a module-specific subfolder +/// (wall/yyyy/MM/<uuid>.ext). First consumer is the wall module; the +/// same endpoint will back recipes and health attachments later on. +/// +public static class UploadFile +{ + /// Command carrying the raw stream + metadata. + /// Open readable stream positioned at the start of the file. + /// MIME type as declared by the client. + /// Size of the stream in bytes. + /// Logical folder under the storage root (e.g. wall). + public sealed record Command( + Stream Stream, + string ContentType, + long SizeBytes, + string Module) : IRequest>; + + /// Handler โ€” writes to disk then persists the asset row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly FilesOptions _options; + private readonly TimeProvider _clock; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + IOptions options, + TimeProvider clock) + { + _db = db; + _userContext = userContext; + _options = options.Value; + _clock = clock; + } + + /// + public async Task> 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, + }; + } +} diff --git a/src/FamilyNido.Api/Features/Health/AddMedication.cs b/src/FamilyNido.Api/Features/Health/AddMedication.cs new file mode 100644 index 0000000..dacde29 --- /dev/null +++ b/src/FamilyNido.Api/Features/Health/AddMedication.cs @@ -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; + +/// Slice for POST /api/health/members/{memberId}/medications. +public static class AddMedication +{ + /// Command for a new medication row. + public sealed record Command( + Guid MemberId, + string Name, + string? Dose, + string? Frequency, + DateOnly StartDate, + DateOnly? EndDate, + string? Instructions) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + 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."); + } + } + + /// Inserts after verifying family scope. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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(); + } +} diff --git a/src/FamilyNido.Api/Features/Health/AddVaccination.cs b/src/FamilyNido.Api/Features/Health/AddVaccination.cs new file mode 100644 index 0000000..4bc4cc8 --- /dev/null +++ b/src/FamilyNido.Api/Features/Health/AddVaccination.cs @@ -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; + +/// Slice for POST /api/health/members/{memberId}/vaccinations. +public static class AddVaccination +{ + /// Command for a new vaccination row. + public sealed record Command( + Guid MemberId, + string Name, + DateOnly Date, + DateOnly? NextDueDate, + string? Notes) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + 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."); + } + } + + /// Inserts after verifying family scope. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Health/DeleteMedication.cs b/src/FamilyNido.Api/Features/Health/DeleteMedication.cs new file mode 100644 index 0000000..44875b9 --- /dev/null +++ b/src/FamilyNido.Api/Features/Health/DeleteMedication.cs @@ -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; + +/// Slice for DELETE /api/health/medications/{id}. +public static class DeleteMedication +{ + /// Command carrying the row to delete. + public sealed record Command(Guid Id) : IRequest>; + + /// Empty success payload. + public sealed record Unit; + + /// Removes the row after verifying family scope. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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(); + } + } +} diff --git a/src/FamilyNido.Api/Features/Health/DeleteVaccination.cs b/src/FamilyNido.Api/Features/Health/DeleteVaccination.cs new file mode 100644 index 0000000..7f47607 --- /dev/null +++ b/src/FamilyNido.Api/Features/Health/DeleteVaccination.cs @@ -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; + +/// Slice for DELETE /api/health/vaccinations/{id}. +public static class DeleteVaccination +{ + /// Command carrying the row to delete. + public sealed record Command(Guid Id) : IRequest>; + + /// Empty success payload. + public sealed record Unit; + + /// Removes the row after verifying family scope. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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(); + } + } +} diff --git a/src/FamilyNido.Api/Features/Health/GetMemberHealth.cs b/src/FamilyNido.Api/Features/Health/GetMemberHealth.cs new file mode 100644 index 0000000..32d20f4 --- /dev/null +++ b/src/FamilyNido.Api/Features/Health/GetMemberHealth.cs @@ -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; + +/// +/// Slice for GET /api/health/members/{memberId}. 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. +/// +public static class GetMemberHealth +{ + /// Query carrying the target member id. + public sealed record Query(Guid MemberId) : IRequest>; + + /// Resolves the member, validates family scope, hydrates the DTO. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Health/HealthDtos.cs b/src/FamilyNido.Api/Features/Health/HealthDtos.cs new file mode 100644 index 0000000..b367ebd --- /dev/null +++ b/src/FamilyNido.Api/Features/Health/HealthDtos.cs @@ -0,0 +1,57 @@ +namespace FamilyNido.Api.Features.Health; + +/// Aggregate "health card" for a single member returned by GET /api/health/members/{id}. +/// Family member id. +/// Convenience copy of the member's name for headers. +/// Static profile (blood, allergies, conditions, notes). Null when never saved. +/// Vaccinations sorted by date descending. +/// Medications sorted by start date descending. +public sealed record MemberHealthDto( + Guid MemberId, + string MemberDisplayName, + HealthProfileDto? Profile, + IReadOnlyList Vaccinations, + IReadOnlyList Medications); + +/// Body of the static health profile. +/// "A+", "O-", "AB+"โ€ฆ or null when unknown. +/// Free-text list of allergies (multi-line allowed). +/// Free-text list of chronic conditions. +/// Free-text general notes. +public sealed record HealthProfileDto( + string? BloodType, + string? Allergies, + string? ChronicConditions, + string? Notes); + +/// Single vaccination row. +/// Stable id. +/// Vaccine name. +/// Date administered. +/// Next due date when known. +/// Optional notes. +public sealed record VaccinationDto( + Guid Id, + string Name, + DateOnly Date, + DateOnly? NextDueDate, + string? Notes); + +/// Single medication row. +/// Stable id. +/// Medication name. +/// Dose string (free text). +/// Frequency string (free text). +/// Start date. +/// End date when finite. +/// Optional instructions. +/// Computed: true when EndDate is null or >= today (in UTC). +public sealed record MedicationDto( + Guid Id, + string Name, + string? Dose, + string? Frequency, + DateOnly StartDate, + DateOnly? EndDate, + string? Instructions, + bool IsActive); diff --git a/src/FamilyNido.Api/Features/Health/HealthEndpoints.cs b/src/FamilyNido.Api/Features/Health/HealthEndpoints.cs new file mode 100644 index 0000000..f9ab5d6 --- /dev/null +++ b/src/FamilyNido.Api/Features/Health/HealthEndpoints.cs @@ -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; + +/// +/// Endpoints for the health module. Gated to Admin/Adult roles โ€” Guests +/// (children with their own accounts in the future) never see this surface. +/// +public static class HealthEndpoints +{ + /// Registers /api/health/* routes on the given builder. + 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 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 UpsertProfileAsync( + Guid memberId, + UpsertHealthProfileBody body, + IValidator 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 AddVaccinationAsync( + Guid memberId, + VaccinationBody body, + IValidator 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 UpdateVaccinationAsync( + Guid id, + VaccinationBody body, + IValidator 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 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 AddMedicationAsync( + Guid memberId, + MedicationBody body, + IValidator 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 UpdateMedicationAsync( + Guid id, + MedicationBody body, + IValidator 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 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(); + } + + /// Body for the health-profile upsert (memberId comes from the route). + public sealed record UpsertHealthProfileBody( + string? BloodType, + string? Allergies, + string? ChronicConditions, + string? Notes); + + /// Body shared by add/update vaccination (memberId/id come from the route). + public sealed record VaccinationBody( + string Name, + DateOnly Date, + DateOnly? NextDueDate, + string? Notes); + + /// Body shared by add/update medication. + public sealed record MedicationBody( + string Name, + string? Dose, + string? Frequency, + DateOnly StartDate, + DateOnly? EndDate, + string? Instructions); +} diff --git a/src/FamilyNido.Api/Features/Health/UpdateMedication.cs b/src/FamilyNido.Api/Features/Health/UpdateMedication.cs new file mode 100644 index 0000000..735e1d9 --- /dev/null +++ b/src/FamilyNido.Api/Features/Health/UpdateMedication.cs @@ -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; + +/// Slice for PUT /api/health/medications/{id}. +public static class UpdateMedication +{ + /// Command replacing the editable fields of a medication row. + public sealed record Command( + Guid Id, + string Name, + string? Dose, + string? Frequency, + DateOnly StartDate, + DateOnly? EndDate, + string? Instructions) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + 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); + } + } + + /// Updates after verifying family scope. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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(); + } +} diff --git a/src/FamilyNido.Api/Features/Health/UpdateVaccination.cs b/src/FamilyNido.Api/Features/Health/UpdateVaccination.cs new file mode 100644 index 0000000..510da94 --- /dev/null +++ b/src/FamilyNido.Api/Features/Health/UpdateVaccination.cs @@ -0,0 +1,77 @@ +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; + +/// Slice for PUT /api/health/vaccinations/{id}. +public static class UpdateVaccination +{ + /// Command replacing the editable fields of a vaccination row. + public sealed record Command( + Guid Id, + string Name, + DateOnly Date, + DateOnly? NextDueDate, + string? Notes) : IRequest>; + + /// Input validation โ€” mirrors . + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Id).NotEmpty(); + 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); + } + } + + /// Updates after verifying family scope. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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."); + } + + entry.Name = request.Name.Trim(); + entry.Date = request.Date; + entry.NextDueDate = request.NextDueDate; + entry.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + + await _db.SaveChangesAsync(cancellationToken); + + return new VaccinationDto(entry.Id, entry.Name, entry.Date, entry.NextDueDate, entry.Notes); + } + } +} diff --git a/src/FamilyNido.Api/Features/Health/UpsertHealthProfile.cs b/src/FamilyNido.Api/Features/Health/UpsertHealthProfile.cs new file mode 100644 index 0000000..f6690f1 --- /dev/null +++ b/src/FamilyNido.Api/Features/Health/UpsertHealthProfile.cs @@ -0,0 +1,90 @@ +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; + +/// +/// Slice for PUT /api/health/members/{memberId}/profile. Lazily creates +/// the row on first save, then updates in place. Free-text fields are trimmed +/// and converted to null when blank. +/// +public static class UpsertHealthProfile +{ + /// Replace the member's profile in full. + public sealed record Command( + Guid MemberId, + string? BloodType, + string? Allergies, + string? ChronicConditions, + string? Notes) : IRequest>; + + /// Validation: cap each field to its persisted length. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.BloodType).MaximumLength(8); + RuleFor(x => x.Allergies).MaximumLength(2000); + RuleFor(x => x.ChronicConditions).MaximumLength(2000); + RuleFor(x => x.Notes).MaximumLength(4000); + } + } + + /// Performs the upsert with permission gating. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 + .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 + .FirstOrDefaultAsync(p => p.FamilyMemberId == request.MemberId, cancellationToken); + if (profile is null) + { + profile = new HealthProfile { FamilyMemberId = request.MemberId }; + _db.HealthProfiles.Add(profile); + } + + profile.BloodType = Trim(request.BloodType); + profile.Allergies = Trim(request.Allergies); + profile.ChronicConditions = Trim(request.ChronicConditions); + profile.Notes = Trim(request.Notes); + + await _db.SaveChangesAsync(cancellationToken); + + return new HealthProfileDto(profile.BloodType, profile.Allergies, profile.ChronicConditions, profile.Notes); + } + + private static string? Trim(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/ArchiveHouseholdTask.cs b/src/FamilyNido.Api/Features/HouseholdTasks/ArchiveHouseholdTask.cs new file mode 100644 index 0000000..f3ea074 --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/ArchiveHouseholdTask.cs @@ -0,0 +1,55 @@ +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.HouseholdTasks; + +/// Slice: soft-delete a task preserving its completions (RF-TASK-005). +public static class ArchiveHouseholdTask +{ + /// Command carrying the target id. + public sealed record Command(Guid TaskId) : IRequest>; + + /// Handler โ€” flips IsArchived to true. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 task = await _db.HouseholdTasks + .Include(t => t.RelatedMembers) + .FirstOrDefaultAsync( + t => t.Id == request.TaskId && t.FamilyId == current.Family.Id, + cancellationToken); + + if (task is null) + { + return ApplicationError.NotFound("household_task.not_found", $"Task {request.TaskId} not found."); + } + + task.IsArchived = true; + await _db.SaveChangesAsync(cancellationToken); + + return HouseholdTaskDto.From(task); + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/CompleteOccurrence.cs b/src/FamilyNido.Api/Features/HouseholdTasks/CompleteOccurrence.cs new file mode 100644 index 0000000..ba59b0f --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/CompleteOccurrence.cs @@ -0,0 +1,97 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.HouseholdTasks; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// Slice: mark a specific occurrence of a task as done (RF-TASK-003 + RF-TASK-007). +public static class CompleteOccurrence +{ + /// Command carrying the target task, date and optional note. + public sealed record Command(Guid TaskId, DateOnly OccurrenceDate, string? Note) + : IRequest>; + + /// Handler โ€” persists the completion. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _clock; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + TimeProvider clock) + { + _db = db; + _userContext = userContext; + _clock = clock; + } + + /// + public async Task> 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 task = await _db.HouseholdTasks + .Include(t => t.Completions.Where(c => c.OccurrenceDate == request.OccurrenceDate)) + .FirstOrDefaultAsync( + t => t.Id == request.TaskId && t.FamilyId == current.Family.Id, + cancellationToken); + + if (task is null) + { + return ApplicationError.NotFound("household_task.not_found", $"Task {request.TaskId} not found."); + } + + if (task.IsArchived) + { + return ApplicationError.Conflict( + "household_task.archived", + "Archived tasks cannot be completed; restore them first."); + } + + // Floating tasks bypass the schedule check โ€” they're "do me whenever" + // chores and the completion date is whatever day the user actually did + // them. Recurring/single-shot tasks still must own that occurrence. + if (!task.IsFloating && !task.HasOccurrenceOn(request.OccurrenceDate)) + { + return ApplicationError.Validation( + "household_task.no_such_occurrence", + $"Task {task.Id} is not scheduled on {request.OccurrenceDate:yyyy-MM-dd}."); + } + + var existing = task.Completions.FirstOrDefault(); + if (existing is not null) + { + // Idempotent โ€” return the existing completion. + return TaskOccurrenceDto.From(task, request.OccurrenceDate, existing); + } + + var completion = new TaskCompletion + { + TaskId = task.Id, + OccurrenceDate = request.OccurrenceDate, + CompletedByMemberId = current.Member.Id, + CompletedAt = _clock.GetUtcNow(), + Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note, + }; + + _db.TaskCompletions.Add(completion); + await _db.SaveChangesAsync(cancellationToken); + + return TaskOccurrenceDto.From(task, request.OccurrenceDate, completion); + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/CreateHouseholdTask.cs b/src/FamilyNido.Api/Features/HouseholdTasks/CreateHouseholdTask.cs new file mode 100644 index 0000000..6a1f57d --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/CreateHouseholdTask.cs @@ -0,0 +1,181 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Features.Notifications; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.HouseholdTasks; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// Slice: create a new under the caller's family (RF-TASK-001). +public static class CreateHouseholdTask +{ + /// Command carrying the input payload. + /// Short title. 1-120 chars. + /// Optional longer description. + /// Free-form category label (defaults to "General"). + /// Recurrence mode. + /// Selected weekdays when is Weekly. + /// Day of month (1..31 or -1) when is Monthly. + /// Optional informative time-of-day target. + /// Pivot date (defaults to today if null at call site). + /// Target date for single-shot tasks. + /// The single member that will execute the task. Null leaves it open for anyone. + /// Members the task concerns (the "about-whom" of the chore). + /// True for "do me whenever" tasks: pending in Hoy every day until completed once. + /// Reward (1..10) earned by whoever marks an occurrence done. + public sealed record Command( + string Title, + string? Description, + string? Category, + RecurrenceMode Recurrence, + DayOfWeekMask? WeeklyDays, + int? MonthlyDay, + TimeOnly? TimeOfDay, + DateOnly StartDate, + DateOnly? DueDate, + Guid? ResponsibleMemberId, + IReadOnlyList? RelatedMemberIds, + bool IsFloating, + int Points) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(120); + + RuleFor(x => x.Description) + .MaximumLength(2000); + + RuleFor(x => x.Category) + .MaximumLength(40); + + RuleFor(x => x.WeeklyDays) + .NotNull() + .NotEqual(DayOfWeekMask.None) + .When(x => x.Recurrence == RecurrenceMode.Weekly) + .WithMessage("Weekly tasks must select at least one weekday."); + + RuleFor(x => x.MonthlyDay) + .NotNull() + .Must(d => d is -1 || (d >= 1 && d <= 31)) + .When(x => x.Recurrence == RecurrenceMode.Monthly) + .WithMessage("MonthlyDay must be 1..31 or -1 (last day of month)."); + + RuleFor(x => x.DueDate) + .Null() + .When(x => x.Recurrence != RecurrenceMode.None) + .WithMessage("DueDate is only valid for non-recurring tasks."); + + // Floating tasks have no schedule. Allowing recurrence or DueDate + // alongside IsFloating would produce ambiguous semantics. + RuleFor(x => x.Recurrence) + .Equal(RecurrenceMode.None) + .When(x => x.IsFloating) + .WithMessage("Floating tasks must use recurrence None."); + RuleFor(x => x.DueDate) + .Null() + .When(x => x.IsFloating) + .WithMessage("Floating tasks cannot have a target date."); + + RuleFor(x => x.Points) + .InclusiveBetween(1, 10) + .WithMessage("Points must be between 1 and 10."); + } + } + + /// Handler โ€” persists a new task row linked to the caller as creator. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly NotificationService _notifications; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + NotificationService notifications) + { + _db = db; + _userContext = userContext; + _notifications = notifications; + } + + /// + public async Task> 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."); + } + + // Resolve and validate the responsible member (if any) โ€” must belong to + // the same family. We do this in a single query that also covers the + // related members so the round-trip count stays at one. + var memberIdsToValidate = new HashSet(request.RelatedMemberIds ?? []); + if (request.ResponsibleMemberId is { } responsibleId) + { + memberIdsToValidate.Add(responsibleId); + } + + var foundMembers = memberIdsToValidate.Count == 0 + ? [] + : await _db.FamilyMembers + .Where(m => m.FamilyId == current.Family.Id && memberIdsToValidate.Contains(m.Id)) + .ToListAsync(cancellationToken); + + if (foundMembers.Count != memberIdsToValidate.Count) + { + return ApplicationError.Validation( + "household_task.unknown_member", + "One or more referenced members are not part of this family."); + } + + // Related members exclude the responsible โ€” same person on both sides + // would be a meaningless duplicate. + var relatedIds = (request.RelatedMemberIds ?? []) + .Where(id => id != request.ResponsibleMemberId) + .ToHashSet(); + var related = foundMembers.Where(m => relatedIds.Contains(m.Id)).ToList(); + + var task = new HouseholdTask + { + FamilyId = current.Family.Id, + Title = request.Title, + Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description, + Category = string.IsNullOrWhiteSpace(request.Category) ? "General" : request.Category, + Recurrence = request.IsFloating ? RecurrenceMode.None : request.Recurrence, + WeeklyDays = request.Recurrence == RecurrenceMode.Weekly && !request.IsFloating ? request.WeeklyDays : null, + MonthlyDay = request.Recurrence == RecurrenceMode.Monthly && !request.IsFloating ? request.MonthlyDay : null, + TimeOfDay = request.TimeOfDay, + StartDate = request.StartDate, + DueDate = request.IsFloating || request.Recurrence != RecurrenceMode.None ? null : request.DueDate, + CreatedByMemberId = current.Member.Id, + ResponsibleMemberId = request.ResponsibleMemberId, + RelatedMembers = related, + IsFloating = request.IsFloating, + Points = request.Points, + }; + + _db.HouseholdTasks.Add(task); + await _db.SaveChangesAsync(cancellationToken); + + if (request.ResponsibleMemberId is { } assignedId) + { + await _notifications.NotifyTaskAssignedAsync(assignedId, current.Member.Id, task, cancellationToken); + } + + return HouseholdTaskDto.From(task); + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/DayTasksDto.cs b/src/FamilyNido.Api/Features/HouseholdTasks/DayTasksDto.cs new file mode 100644 index 0000000..02fc979 --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/DayTasksDto.cs @@ -0,0 +1,14 @@ +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// +/// A calendar day bundled with the tasks scheduled on it. Returned by +/// and . +/// +/// The date this bucket corresponds to. +/// Tasks scheduled on with their occurrence state. +public sealed record DayTasksDto(DateOnly Date, IReadOnlyList Tasks); + +/// Pair of task + its occurrence-state on a specific date. +/// The task. +/// The occurrence for the bundle date (may be uncompleted). +public sealed record TaskOnDateDto(HouseholdTaskDto Task, TaskOccurrenceDto Occurrence); diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/DeleteHouseholdTask.cs b/src/FamilyNido.Api/Features/HouseholdTasks/DeleteHouseholdTask.cs new file mode 100644 index 0000000..1f2129c --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/DeleteHouseholdTask.cs @@ -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.Domain.Families; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// Slice: hard-delete a task. Only the creator or a family admin may delete (RF-TASK-004). +public static class DeleteHouseholdTask +{ + /// Command carrying the target id. + public sealed record Command(Guid TaskId) : IRequest>; + + /// Handler โ€” enforces the creator-or-admin rule before deleting. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 task = await _db.HouseholdTasks + .FirstOrDefaultAsync( + t => t.Id == request.TaskId && t.FamilyId == current.Family.Id, + cancellationToken); + + if (task is null) + { + return ApplicationError.NotFound("household_task.not_found", $"Task {request.TaskId} not found."); + } + + var isAdmin = current.User.Role == FamilyRole.Admin; + var isCreator = current.Member.Id == task.CreatedByMemberId; + if (!isAdmin && !isCreator) + { + return ApplicationError.Forbidden( + "household_task.only_creator_or_admin_can_delete", + "Only the creator or a family admin may delete a task."); + } + + _db.HouseholdTasks.Remove(task); + await _db.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/GetHouseholdTask.cs b/src/FamilyNido.Api/Features/HouseholdTasks/GetHouseholdTask.cs new file mode 100644 index 0000000..3e3073b --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/GetHouseholdTask.cs @@ -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.HouseholdTasks; + +/// Slice: fetch one task by id, scoped to the caller's family. +public static class GetHouseholdTask +{ + /// Query carrying the target id. + public sealed record Query(Guid TaskId) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 task = await _db.HouseholdTasks + .AsNoTracking() + .Include(t => t.RelatedMembers) + .FirstOrDefaultAsync( + t => t.Id == request.TaskId && t.FamilyId == current.Family.Id, + cancellationToken); + + return task is null + ? ApplicationError.NotFound("household_task.not_found", $"Task {request.TaskId} not found.") + : HouseholdTaskDto.From(task); + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/GetTodayTasks.cs b/src/FamilyNido.Api/Features/HouseholdTasks/GetTodayTasks.cs new file mode 100644 index 0000000..fe98533 --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/GetTodayTasks.cs @@ -0,0 +1,161 @@ +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.HouseholdTasks; + +/// Slice: tasks scheduled today, with their completion state (RF-TASK-006). +public static class GetTodayTasks +{ + /// Parameterless query โ€” the date is derived from the family's time zone. + public sealed record Query : IRequest>; + + /// Handler that loads tasks, expands occurrences and cross-references completions. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _clock; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider clock) + { + _db = db; + _userContext = userContext; + _clock = clock; + } + + /// + public async Task> 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 today = TodayInFamilyZone(_clock, current.Family.TimeZone); + var day = await LoadRangeAsync(_db, current.Family.Id, today, today, today, cancellationToken); + + return day.Count > 0 + ? day[0] + : new DayTasksDto(today, []); + } + + /// + /// Resolve "today" using the family's configured IANA time zone. Falls back to UTC + /// when the zone is unknown โ€” deployment issue we don't want to crash over. Shared + /// with . + /// + internal static DateOnly TodayInFamilyZone(TimeProvider clock, string ianaZone) + { + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(ianaZone); + var localNow = TimeZoneInfo.ConvertTime(clock.GetUtcNow(), tz); + return DateOnly.FromDateTime(localNow.DateTime); + } + catch (TimeZoneNotFoundException) + { + return DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime); + } + } + + /// + /// Shared loader used by and : + /// pulls non-archived tasks + their completions in the window, then expands + /// occurrences in-memory via . + /// + /// + /// Floating tasks live outside the recurrence engine: they appear on the + /// date (when present) for as long as no + /// completion exists. Pass null to skip them entirely (e.g. when + /// rendering a past week that never includes "today"). + /// + internal static async Task> LoadRangeAsync( + ApplicationDbContext db, + Guid familyId, + DateOnly from, + DateOnly to, + DateOnly? todayAnchor, + CancellationToken ct) + { + var tasks = await db.HouseholdTasks + .AsNoTracking() + .Include(t => t.RelatedMembers) + .Include(t => t.Completions.Where(c => c.OccurrenceDate >= from && c.OccurrenceDate <= to)) + .Where(t => t.FamilyId == familyId && !t.IsArchived) + .ToListAsync(ct); + + // Floating + deadline-range tasks need a *global* completion check (any + // completion at all marks them "done forever"), but we only loaded + // completions inside the window above. Run a focused side query for + // both flavours in one shot. + var globalCheckTaskIds = tasks + .Where(t => t.IsFloating || t.IsDeadlineRange) + .Select(t => t.Id) + .ToList(); + HashSet globallyDone; + if (globalCheckTaskIds.Count == 0) + { + globallyDone = []; + } + else + { + var ids = await db.TaskCompletions + .AsNoTracking() + .Where(c => globalCheckTaskIds.Contains(c.TaskId)) + .Select(c => c.TaskId) + .Distinct() + .ToListAsync(ct); + globallyDone = ids.ToHashSet(); + } + + var days = new List(); + for (var d = from; d <= to; d = d.AddDays(1)) + { + var items = new List(); + foreach (var task in tasks) + { + if (task.IsFloating) + { + // Skip floating tasks already completed at any point โ€” they + // graduate from the pending list forever. + if (globallyDone.Contains(task.Id)) continue; + // Surface them on the "today" anchor only so the week view + // doesn't repeat the same row 7 times. + if (todayAnchor is null || d != todayAnchor.Value) continue; + + items.Add(new TaskOnDateDto( + HouseholdTaskDto.From(task), + TaskOccurrenceDto.From(task, d, completion: null))); + continue; + } + + // Deadline-range tasks (non-recurring, StartDate < DueDate) appear + // every day in their window unless any completion already exists + // anywhere โ€” same "done forever" rule as floating tasks. + if (task.IsDeadlineRange && globallyDone.Contains(task.Id)) + { + continue; + } + + if (!task.HasOccurrenceOn(d)) + { + continue; + } + + var completion = task.Completions.FirstOrDefault(c => c.OccurrenceDate == d); + items.Add(new TaskOnDateDto( + HouseholdTaskDto.From(task), + TaskOccurrenceDto.From(task, d, completion))); + } + days.Add(new DayTasksDto(d, items)); + } + return days; + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/GetWeekTasks.cs b/src/FamilyNido.Api/Features/HouseholdTasks/GetWeekTasks.cs new file mode 100644 index 0000000..981c53b --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/GetWeekTasks.cs @@ -0,0 +1,62 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Persistence; + +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// +/// Slice: 7-day view starting at (defaults to today). +/// The week is always 7 consecutive days starting at the given Monday/day; the caller +/// picks the start date because weeks are culturally variable (Mon vs Sun). +/// +public static class GetWeekTasks +{ + /// Query carrying the optional start date. + /// First day of the 7-day window. Null โ‡’ today in family TZ. + public sealed record Query(DateOnly? StartDate) : IRequest>>; + + /// Handler. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _clock; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider clock) + { + _db = db; + _userContext = userContext; + _clock = clock; + } + + /// + public async Task>> 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 today = GetTodayTasks.Handler.TodayInFamilyZone(_clock, current.Family.TimeZone); + var from = request.StartDate ?? today; + var to = from.AddDays(6); + + // Floating tasks are anchored on the family's "today" only when it + // falls within the requested week โ€” otherwise the past/future week + // wouldn't carry them. + var anchor = today >= from && today <= to ? (DateOnly?)today : null; + + var days = await GetTodayTasks.Handler.LoadRangeAsync( + _db, current.Family.Id, from, to, anchor, cancellationToken); + + IReadOnlyList result = days; + return Result>.Success(result); + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/HouseholdTaskDto.cs b/src/FamilyNido.Api/Features/HouseholdTasks/HouseholdTaskDto.cs new file mode 100644 index 0000000..f3b321d --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/HouseholdTaskDto.cs @@ -0,0 +1,75 @@ +using FamilyNido.Domain.HouseholdTasks; + +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// +/// Read-model projection of a returned by the API. +/// +/// Stable task identifier. +/// Short title shown in the list. +/// Optional longer description. +/// Free-form category label. +/// Recurrence mode. +/// Bitmask of weekdays when is Weekly. +/// Day of the month (1โ€“31 or -1 for "last day") when is Monthly. +/// Informative time-of-day target, if any. +/// Pivot date: no occurrences are generated before this date. +/// Target date for single-shot tasks. +/// The single member who executes the task (null = open, anyone can do it). +/// Ids of the members the task concerns (zero, one, or many). +/// Whether the task is archived. +/// When true the task has no fixed date and stays pending in "Hoy" until completed once. +/// Member who created the task; only they or an admin can delete it. +/// UTC instant of creation. +/// Reward (1..10) earned by whoever marks an occurrence as done. +/// Most recent completion of the task, or null if never completed. +public sealed record HouseholdTaskDto( + Guid Id, + string Title, + string? Description, + string Category, + RecurrenceMode Recurrence, + DayOfWeekMask? WeeklyDays, + int? MonthlyDay, + TimeOnly? TimeOfDay, + DateOnly StartDate, + DateOnly? DueDate, + Guid? ResponsibleMemberId, + IReadOnlyList RelatedMemberIds, + bool IsArchived, + bool IsFloating, + Guid CreatedByMemberId, + DateTimeOffset CreatedAt, + int Points, + LatestCompletionDto? LatestCompletion) +{ + /// Project a domain entity to the DTO shape used by the API. + public static HouseholdTaskDto From(HouseholdTask t, LatestCompletionDto? latest = null) => new( + Id: t.Id, + Title: t.Title, + Description: t.Description, + Category: t.Category, + Recurrence: t.Recurrence, + WeeklyDays: t.WeeklyDays, + MonthlyDay: t.MonthlyDay, + TimeOfDay: t.TimeOfDay, + StartDate: t.StartDate, + DueDate: t.DueDate, + ResponsibleMemberId: t.ResponsibleMemberId, + RelatedMemberIds: [.. t.RelatedMembers.Select(a => a.Id)], + IsArchived: t.IsArchived, + IsFloating: t.IsFloating, + CreatedByMemberId: t.CreatedByMemberId, + CreatedAt: t.CreatedAt, + Points: t.Points, + LatestCompletion: latest); +} + +/// Compact view of the most recent completion of a task. +/// Date of the occurrence the member ticked. +/// Member who completed it (null if the row is anonymised). +/// UTC instant the completion was registered. +public sealed record LatestCompletionDto( + DateOnly OccurrenceDate, + Guid? CompletedByMemberId, + DateTimeOffset CompletedAt); diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/HouseholdTaskEndpoints.cs b/src/FamilyNido.Api/Features/HouseholdTasks/HouseholdTaskEndpoints.cs new file mode 100644 index 0000000..6a9b88b --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/HouseholdTaskEndpoints.cs @@ -0,0 +1,233 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; +using FluentValidation; + +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// REST endpoints for shared household tasks (RF-TASK-*). +public static class HouseholdTaskEndpoints +{ + /// Registers /api/household-tasks endpoints on the given route group. + public static IEndpointRouteBuilder MapHouseholdTaskEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/household-tasks") + .WithTags("HouseholdTasks") + .RequireAuthorization(Policies.AuthenticatedUser); + + group.MapGet("/", ListAsync); + group.MapGet("/today", TodayAsync); + group.MapGet("/week", WeekAsync); + group.MapGet("/{id:guid}", GetAsync); + group.MapPost("/", CreateAsync); + group.MapPatch("/{id:guid}", UpdateAsync); + group.MapPost("/{id:guid}/archive", ArchiveAsync); + group.MapPost("/{id:guid}/restore", RestoreAsync); + group.MapDelete("/{id:guid}", DeleteAsync); + group.MapPost("/{id:guid}/occurrences/{date}/complete", CompleteOccurrenceAsync); + group.MapPost("/{id:guid}/occurrences/{date}/undo", UndoOccurrenceAsync); + group.MapPut("/{id:guid}/occurrences/{date}/completion", SetAttributionAsync) + .RequireAuthorization(Policies.Admin); + group.MapGet("/{id:guid}/completions", ListCompletionsAsync); + + return app; + } + + private static async Task ListAsync( + bool? includeArchived, + Guid? memberId, + Guid? assigneeId, + IMediator mediator, + CancellationToken ct) + { + // Accept the legacy `assigneeId` query param as an alias for `memberId` + // so old clients keep working during the rollout. Drop after the front + // is fully migrated. + var effectiveMemberId = memberId ?? assigneeId; + var result = await mediator.SendAsync( + new ListHouseholdTasks.Query(includeArchived ?? false, effectiveMemberId), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task TodayAsync(IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new GetTodayTasks.Query(), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task WeekAsync( + DateOnly? startDate, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new GetWeekTasks.Query(startDate), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task GetAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new GetHouseholdTask.Query(id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task CreateAsync( + CreateHouseholdTask.Command command, + IValidator 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/household-tasks/{result.Value.Id}", result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task UpdateAsync( + Guid id, + UpdateHouseholdTaskBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new UpdateHouseholdTask.Command( + id, + body.Title, + body.Description, + body.Category, + body.Recurrence, + body.WeeklyDays, + body.MonthlyDay, + body.TimeOfDay, + body.StartDate, + body.DueDate, + body.ResponsibleMemberId, + body.RelatedMemberIds, + body.IsFloating, + body.Points); + + 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 ArchiveAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new ArchiveHouseholdTask.Command(id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task RestoreAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new RestoreHouseholdTask.Command(id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task DeleteAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new DeleteHouseholdTask.Command(id), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } + + private static async Task CompleteOccurrenceAsync( + Guid id, + DateOnly date, + CompleteOccurrenceBody? body, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync( + new CompleteOccurrence.Command(id, date, body?.Note), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task UndoOccurrenceAsync( + Guid id, + DateOnly date, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new UndoOccurrence.Command(id, date), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task ListCompletionsAsync( + Guid id, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new ListTaskCompletions.Query(id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task SetAttributionAsync( + Guid id, + DateOnly date, + SetCompletionAttributionBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new SetCompletionAttribution.Command( + id, date, body.CompletedByMemberId, body.Note); + + 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(); + } +} + +/// +/// Wire-level payload for PATCH /api/household-tasks/{id}. The route parameter carries the id. +/// +/// Title of the task. +/// Optional longer description. +/// Category label. +/// Recurrence mode. +/// Weekdays when Weekly. +/// Day of month when Monthly. +/// Informative hour. +/// Pivot date. +/// Target date for single-shot tasks. +/// The single member who executes the task. Null leaves it open. +/// Members the task concerns. +/// True for "do me whenever" tasks pending in Hoy until completed once. +/// Reward (1..10) earned by whoever marks an occurrence done. +public sealed record UpdateHouseholdTaskBody( + string Title, + string? Description, + string? Category, + Domain.HouseholdTasks.RecurrenceMode Recurrence, + Domain.HouseholdTasks.DayOfWeekMask? WeeklyDays, + int? MonthlyDay, + TimeOnly? TimeOfDay, + DateOnly StartDate, + DateOnly? DueDate, + Guid? ResponsibleMemberId, + IReadOnlyList? RelatedMemberIds, + bool IsFloating, + int Points); + +/// Wire-level payload for completing an occurrence; body is optional (used only to attach a note). +/// Optional free-text note. +public sealed record CompleteOccurrenceBody(string? Note); + +/// Wire-level payload for the admin attribution PUT. +/// Member who should be credited as the completer. +/// Optional free-text note (replaces any prior note). +public sealed record SetCompletionAttributionBody(Guid CompletedByMemberId, string? Note); diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/ListHouseholdTasks.cs b/src/FamilyNido.Api/Features/HouseholdTasks/ListHouseholdTasks.cs new file mode 100644 index 0000000..7b0b654 --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/ListHouseholdTasks.cs @@ -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.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// Slice: list tasks of the current user's family (RF-TASK-006). +public static class ListHouseholdTasks +{ + /// Query carrying optional filters. + /// When true, archived rows are also returned. + /// + /// When set, only return tasks in which this member appears โ€” either as the + /// responsible (singular) or as one of the related members (N:M). The two + /// roles surface together so the per-member dashboard can show one list. + /// + public sealed record Query(bool IncludeArchived, Guid? MemberId) + : IRequest>>; + + /// Handler backed by EF Core. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task>> 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 query = _db.HouseholdTasks + .AsNoTracking() + .Include(t => t.RelatedMembers) + .Where(t => t.FamilyId == current.Family.Id); + + if (!request.IncludeArchived) + { + query = query.Where(t => !t.IsArchived); + } + + if (request.MemberId is { } memberId) + { + // OR across both roles: a member's dashboard shows tasks they + // execute (responsible) and tasks that concern them (related). + query = query.Where(t => + t.ResponsibleMemberId == memberId + || t.RelatedMembers.Any(a => a.Id == memberId)); + } + + var tasks = await query + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(cancellationToken); + + // For the "Todas" tab in the UI, attach the most recent completion of + // each task โ€” one row per task, the latest by completed_at. Cheap + // single round trip with a window-by-task GROUP BY. + var taskIds = tasks.Select(t => t.Id).ToList(); + var latestRaw = taskIds.Count == 0 + ? [] + : await _db.TaskCompletions + .AsNoTracking() + .Where(c => taskIds.Contains(c.TaskId)) + .GroupBy(c => c.TaskId) + .Select(g => g.OrderByDescending(c => c.CompletedAt).First()) + .ToListAsync(cancellationToken); + var latestByTask = latestRaw.ToDictionary(c => c.TaskId); + + IReadOnlyList dto = [.. + tasks.Select(t => HouseholdTaskDto.From( + t, + latestByTask.TryGetValue(t.Id, out var c) + ? new LatestCompletionDto(c.OccurrenceDate, c.CompletedByMemberId, c.CompletedAt) + : null))]; + return Result>.Success(dto); + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/ListTaskCompletions.cs b/src/FamilyNido.Api/Features/HouseholdTasks/ListTaskCompletions.cs new file mode 100644 index 0000000..03aa457 --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/ListTaskCompletions.cs @@ -0,0 +1,80 @@ +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.HouseholdTasks; + +/// +/// Slice: full per-occurrence completion history for a single task, sorted +/// most recent first. Powers the "Historial" panel in the task form so +/// admins can see and (via the existing PUT endpoint) re-attribute who did +/// the chore on any past day โ€” particularly useful for daily/weekly +/// recurring tasks where the latest completion alone isn't enough. +/// +public static class ListTaskCompletions +{ + /// Query carrying the target task id. + public sealed record Query(Guid TaskId) : IRequest>>; + + /// Handler. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task>> 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."); + } + + // Only callers from the task's family can see history. + var taskExists = await _db.HouseholdTasks.AnyAsync( + t => t.Id == request.TaskId && t.FamilyId == current.Family.Id, + cancellationToken); + if (!taskExists) + { + return ApplicationError.NotFound("household_task.not_found", $"Task {request.TaskId} not found."); + } + + var entries = await _db.TaskCompletions + .AsNoTracking() + .Where(c => c.TaskId == request.TaskId) + .OrderByDescending(c => c.OccurrenceDate) + .Select(c => new TaskCompletionEntryDto( + c.OccurrenceDate, + c.CompletedByMemberId, + c.CompletedAt, + c.Note)) + .ToListAsync(cancellationToken); + + return Result>.Success(entries); + } + } +} + +/// One row in the per-task completion history. +/// Date this completion is for (key with TaskId). +/// Member credited as the completer, or null if anonymous. +/// UTC instant the click happened. +/// Optional note left by the completer. +public sealed record TaskCompletionEntryDto( + DateOnly OccurrenceDate, + Guid? CompletedByMemberId, + DateTimeOffset CompletedAt, + string? Note); diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/RestoreHouseholdTask.cs b/src/FamilyNido.Api/Features/HouseholdTasks/RestoreHouseholdTask.cs new file mode 100644 index 0000000..eba68a8 --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/RestoreHouseholdTask.cs @@ -0,0 +1,55 @@ +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.HouseholdTasks; + +/// Slice: reverse (RF-TASK-005). +public static class RestoreHouseholdTask +{ + /// Command carrying the target id. + public sealed record Command(Guid TaskId) : IRequest>; + + /// Handler โ€” flips IsArchived to false. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 task = await _db.HouseholdTasks + .Include(t => t.RelatedMembers) + .FirstOrDefaultAsync( + t => t.Id == request.TaskId && t.FamilyId == current.Family.Id, + cancellationToken); + + if (task is null) + { + return ApplicationError.NotFound("household_task.not_found", $"Task {request.TaskId} not found."); + } + + task.IsArchived = false; + await _db.SaveChangesAsync(cancellationToken); + + return HouseholdTaskDto.From(task); + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/SetCompletionAttribution.cs b/src/FamilyNido.Api/Features/HouseholdTasks/SetCompletionAttribution.cs new file mode 100644 index 0000000..2f03508 --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/SetCompletionAttribution.cs @@ -0,0 +1,159 @@ +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.Domain.HouseholdTasks; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// +/// Slice: admin-only upsert of the completion record for a single occurrence, +/// attributing it to a chosen family member. Used to fix mistakes ("I clicked +/// done but Dan really did it") and to mark something as done on behalf of +/// somebody else. +/// +/// +/// Endpoint sits at PUT .../occurrences/{date}/completion alongside the +/// existing POST .../complete and POST .../undo. The PUT verb +/// matches the "idempotent upsert" semantics: if no completion exists for the +/// occurrence we create one with CompletedAt = now; if one exists we +/// update its attribution (and the optional note) but keep the original +/// CompletedAt so the audit trail stays honest about *when* the task +/// was first marked done. +/// +public static class SetCompletionAttribution +{ + /// Command carrying the new attribution and an optional note. + /// Owning task. + /// Date of the occurrence being attributed. + /// Member who should appear as the completer. + /// Optional free-text note (replaces any prior note). + public sealed record Command( + Guid TaskId, + DateOnly OccurrenceDate, + Guid CompletedByMemberId, + string? Note) : IRequest>; + + /// Validator โ€” rejects the empty member id at the wire level. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.CompletedByMemberId).NotEmpty(); + RuleFor(x => x.Note).MaximumLength(500); + } + } + + /// Handler โ€” admin-only via endpoint policy; double-checks role here too. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _clock; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider clock) + { + _db = db; + _userContext = userContext; + _clock = clock; + } + + /// + public async Task> 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."); + } + + // Endpoint already gates on the Admin policy, but the handler is + // also reachable from tests that bypass the policy โ€” defend in + // depth so a future refactor doesn't accidentally widen access. + if (current.User.Role != FamilyRole.Admin) + { + return ApplicationError.Forbidden( + "household_task.attribution_admin_only", + "Only admins can change completion attribution."); + } + + var task = await _db.HouseholdTasks + .Include(t => t.Completions.Where(c => c.OccurrenceDate == request.OccurrenceDate)) + .FirstOrDefaultAsync( + t => t.Id == request.TaskId && t.FamilyId == current.Family.Id, + cancellationToken); + + if (task is null) + { + return ApplicationError.NotFound("household_task.not_found", $"Task {request.TaskId} not found."); + } + + if (task.IsArchived) + { + return ApplicationError.Conflict( + "household_task.archived", + "Archived tasks cannot be completed; restore them first."); + } + + if (!task.IsFloating && !task.HasOccurrenceOn(request.OccurrenceDate)) + { + return ApplicationError.Validation( + "household_task.no_such_occurrence", + $"Task {task.Id} is not scheduled on {request.OccurrenceDate:yyyy-MM-dd}."); + } + + // The new completer must be an active member of the same family โ€” + // anything else would let an admin "credit" a person from another + // household, which makes no sense and breaks scoreboard math. + var memberExists = await _db.FamilyMembers.AnyAsync( + m => m.Id == request.CompletedByMemberId + && m.FamilyId == current.Family.Id + && m.IsActive, + cancellationToken); + if (!memberExists) + { + return ApplicationError.Validation( + "household_task.unknown_member", + $"Member {request.CompletedByMemberId} is not an active member of this family."); + } + + var note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note; + var existing = task.Completions.FirstOrDefault(); + + if (existing is null) + { + _db.TaskCompletions.Add(new TaskCompletion + { + TaskId = task.Id, + OccurrenceDate = request.OccurrenceDate, + CompletedByMemberId = request.CompletedByMemberId, + CompletedAt = _clock.GetUtcNow(), + Note = note, + }); + } + else + { + existing.CompletedByMemberId = request.CompletedByMemberId; + existing.Note = note; + // CompletedAt stays โ€” we're correcting attribution, not + // overwriting when the task was actually done. + } + + await _db.SaveChangesAsync(cancellationToken); + + // Re-read so the DTO sees the freshly persisted state. + var fresh = await _db.TaskCompletions.AsNoTracking().FirstOrDefaultAsync( + c => c.TaskId == task.Id && c.OccurrenceDate == request.OccurrenceDate, + cancellationToken); + return TaskOccurrenceDto.From(task, request.OccurrenceDate, fresh); + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/TaskOccurrenceDto.cs b/src/FamilyNido.Api/Features/HouseholdTasks/TaskOccurrenceDto.cs new file mode 100644 index 0000000..0115317 --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/TaskOccurrenceDto.cs @@ -0,0 +1,34 @@ +using FamilyNido.Domain.HouseholdTasks; + +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// +/// Projection of a single task occurrence (scheduled date + optional completion). +/// Used by the "today"/"week" list views and returned from the complete/undo endpoints. +/// +/// Owning task. +/// Date this occurrence belongs to. +/// True when a row exists for this date. +/// Member who marked it done, if any. +/// When the completion was recorded. +/// Optional note left by the completer. +public sealed record TaskOccurrenceDto( + Guid TaskId, + DateOnly OccurrenceDate, + bool IsCompleted, + Guid? CompletedByMemberId, + DateTimeOffset? CompletedAt, + string? Note) +{ + /// Build an occurrence DTO from a task + optional completion row. + public static TaskOccurrenceDto From(HouseholdTask task, DateOnly date, TaskCompletion? completion) => + completion is null + ? new TaskOccurrenceDto(task.Id, date, false, null, null, null) + : new TaskOccurrenceDto( + task.Id, + completion.OccurrenceDate, + true, + completion.CompletedByMemberId, + completion.CompletedAt, + completion.Note); +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/UndoOccurrence.cs b/src/FamilyNido.Api/Features/HouseholdTasks/UndoOccurrence.cs new file mode 100644 index 0000000..e25135d --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/UndoOccurrence.cs @@ -0,0 +1,65 @@ +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.HouseholdTasks; + +/// Slice: revert a previous completion of a task occurrence (RF-TASK-003 + RF-TASK-007). +public static class UndoOccurrence +{ + /// Command carrying the target task and date. + public sealed record Command(Guid TaskId, DateOnly OccurrenceDate) + : IRequest>; + + /// Handler โ€” removes the completion row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 task = await _db.HouseholdTasks + .Include(t => t.Completions.Where(c => c.OccurrenceDate == request.OccurrenceDate)) + .FirstOrDefaultAsync( + t => t.Id == request.TaskId && t.FamilyId == current.Family.Id, + cancellationToken); + + if (task is null) + { + return ApplicationError.NotFound("household_task.not_found", $"Task {request.TaskId} not found."); + } + + var existing = task.Completions.FirstOrDefault(); + if (existing is null) + { + // Idempotent โ€” undoing a not-yet-completed occurrence is a no-op. + return TaskOccurrenceDto.From(task, request.OccurrenceDate, null); + } + + _db.TaskCompletions.Remove(existing); + await _db.SaveChangesAsync(cancellationToken); + + return TaskOccurrenceDto.From(task, request.OccurrenceDate, null); + } + } +} diff --git a/src/FamilyNido.Api/Features/HouseholdTasks/UpdateHouseholdTask.cs b/src/FamilyNido.Api/Features/HouseholdTasks/UpdateHouseholdTask.cs new file mode 100644 index 0000000..39220ab --- /dev/null +++ b/src/FamilyNido.Api/Features/HouseholdTasks/UpdateHouseholdTask.cs @@ -0,0 +1,184 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Features.Notifications; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.HouseholdTasks; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.HouseholdTasks; + +/// Slice: update an existing task. Mutates all editable fields in one shot (RF-TASK-004). +public static class UpdateHouseholdTask +{ + /// Command carrying the target id + mutable payload. + /// Task to update. + /// New title. + /// New description (null clears). + /// Category label. + /// Recurrence mode. + /// Weekday mask when Weekly. + /// Day-of-month when Monthly. + /// Informative time-of-day. + /// Pivot date. + /// Target date for single-shot tasks. + /// The single member who executes the task. Null leaves it open. + /// Members the task concerns. + /// True for "do me whenever" tasks: pending in Hoy until completed once. + /// Reward (1..10) earned by whoever marks an occurrence done. + public sealed record Command( + Guid TaskId, + string Title, + string? Description, + string? Category, + RecurrenceMode Recurrence, + DayOfWeekMask? WeeklyDays, + int? MonthlyDay, + TimeOnly? TimeOfDay, + DateOnly StartDate, + DateOnly? DueDate, + Guid? ResponsibleMemberId, + IReadOnlyList? RelatedMemberIds, + bool IsFloating, + int Points) : IRequest>; + + /// Input validation โ€” shares the rules from . + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.TaskId).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(120); + RuleFor(x => x.Description).MaximumLength(2000); + RuleFor(x => x.Category).MaximumLength(40); + + RuleFor(x => x.WeeklyDays) + .NotNull() + .NotEqual(DayOfWeekMask.None) + .When(x => x.Recurrence == RecurrenceMode.Weekly) + .WithMessage("Weekly tasks must select at least one weekday."); + + RuleFor(x => x.MonthlyDay) + .NotNull() + .Must(d => d is -1 || (d >= 1 && d <= 31)) + .When(x => x.Recurrence == RecurrenceMode.Monthly) + .WithMessage("MonthlyDay must be 1..31 or -1 (last day of month)."); + + RuleFor(x => x.DueDate) + .Null() + .When(x => x.Recurrence != RecurrenceMode.None) + .WithMessage("DueDate is only valid for non-recurring tasks."); + + RuleFor(x => x.Recurrence) + .Equal(RecurrenceMode.None) + .When(x => x.IsFloating) + .WithMessage("Floating tasks must use recurrence None."); + RuleFor(x => x.DueDate) + .Null() + .When(x => x.IsFloating) + .WithMessage("Floating tasks cannot have a target date."); + + RuleFor(x => x.Points) + .InclusiveBetween(1, 10) + .WithMessage("Points must be between 1 and 10."); + } + } + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly NotificationService _notifications; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + NotificationService notifications) + { + _db = db; + _userContext = userContext; + _notifications = notifications; + } + + /// + public async Task> 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 task = await _db.HouseholdTasks + .Include(t => t.RelatedMembers) + .FirstOrDefaultAsync( + t => t.Id == request.TaskId && t.FamilyId == current.Family.Id, + cancellationToken); + + if (task is null) + { + return ApplicationError.NotFound("household_task.not_found", $"Task {request.TaskId} not found."); + } + + // Validate all referenced members in a single round-trip โ€” responsible + // and related ids combined. + var memberIdsToValidate = new HashSet(request.RelatedMemberIds ?? []); + if (request.ResponsibleMemberId is { } responsibleId) + { + memberIdsToValidate.Add(responsibleId); + } + + var foundMembers = memberIdsToValidate.Count == 0 + ? [] + : await _db.FamilyMembers + .Where(m => m.FamilyId == current.Family.Id && memberIdsToValidate.Contains(m.Id)) + .ToListAsync(cancellationToken); + + if (foundMembers.Count != memberIdsToValidate.Count) + { + return ApplicationError.Validation( + "household_task.unknown_member", + "One or more referenced members are not part of this family."); + } + + task.Title = request.Title; + task.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description; + task.Category = string.IsNullOrWhiteSpace(request.Category) ? "General" : request.Category; + task.IsFloating = request.IsFloating; + task.Recurrence = request.IsFloating ? RecurrenceMode.None : request.Recurrence; + task.WeeklyDays = !request.IsFloating && request.Recurrence == RecurrenceMode.Weekly ? request.WeeklyDays : null; + task.MonthlyDay = !request.IsFloating && request.Recurrence == RecurrenceMode.Monthly ? request.MonthlyDay : null; + task.TimeOfDay = request.TimeOfDay; + task.StartDate = request.StartDate; + task.DueDate = request.IsFloating || request.Recurrence != RecurrenceMode.None ? null : request.DueDate; + task.Points = request.Points; + // Capture the previous responsible so we only fire on actual changes. + var previousResponsibleId = task.ResponsibleMemberId; + task.ResponsibleMemberId = request.ResponsibleMemberId; + + // Replace the related set; the responsible never doubles as related. + task.RelatedMembers.Clear(); + var relatedIds = (request.RelatedMemberIds ?? []) + .Where(id => id != request.ResponsibleMemberId) + .ToHashSet(); + foreach (var related in foundMembers.Where(m => relatedIds.Contains(m.Id))) + { + task.RelatedMembers.Add(related); + } + + await _db.SaveChangesAsync(cancellationToken); + + if (task.ResponsibleMemberId is { } assignedId && assignedId != previousResponsibleId) + { + await _notifications.NotifyTaskAssignedAsync(assignedId, current.Member.Id, task, cancellationToken); + } + + return HouseholdTaskDto.From(task); + } + } +} diff --git a/src/FamilyNido.Api/Features/Integrations/CreateIntegrationApiKey.cs b/src/FamilyNido.Api/Features/Integrations/CreateIntegrationApiKey.cs new file mode 100644 index 0000000..c4f6934 --- /dev/null +++ b/src/FamilyNido.Api/Features/Integrations/CreateIntegrationApiKey.cs @@ -0,0 +1,73 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Integrations; +using FamilyNido.Persistence; +using FluentValidation; + +namespace FamilyNido.Api.Features.Integrations; + +/// +/// Slice for POST /api/integrations/api-keys โ€” admin mints a new token. +/// The plaintext is returned exactly once; only the digest stays in the DB. +/// +public static class CreateIntegrationApiKey +{ + /// Command carrying the desired display name. + /// Human-readable label, e.g. "my automation". 1..80 chars. + public sealed record Command(string Name) : IRequest>; + + /// Validates the input. + public sealed class Validator : AbstractValidator + { + /// Creates the validator. + public Validator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(80); + } + } + + /// Generates the token, persists the digest, returns the plaintext exactly once. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 plaintext = IntegrationTokens.Generate(); + var key = new IntegrationApiKey + { + FamilyId = current.Family.Id, + AuthorMemberId = current.Member.Id, + Name = request.Name.Trim(), + TokenHash = IntegrationTokens.Hash(plaintext), + Prefix = IntegrationTokens.PrefixOf(plaintext), + }; + + _db.IntegrationApiKeys.Add(key); + await _db.SaveChangesAsync(cancellationToken); + + var dto = new IntegrationApiKeyDto( + key.Id, key.Name, key.Prefix, key.CreatedAt, key.LastUsedAt, key.RevokedAt); + return new CreatedIntegrationApiKeyDto(plaintext, dto); + } + } +} diff --git a/src/FamilyNido.Api/Features/Integrations/IntegrationApiKeyAuthentication.cs b/src/FamilyNido.Api/Features/Integrations/IntegrationApiKeyAuthentication.cs new file mode 100644 index 0000000..36018af --- /dev/null +++ b/src/FamilyNido.Api/Features/Integrations/IntegrationApiKeyAuthentication.cs @@ -0,0 +1,149 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using FamilyNido.Persistence; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace FamilyNido.Api.Features.Integrations; + +/// Constants for the integration API-key authentication scheme. +public static class IntegrationApiKeyDefaults +{ + /// Auth scheme name used by endpoints under /api/integrations/**. + public const string AuthenticationScheme = "IntegrationApiKey"; + + /// Custom header alternative to Authorization: Bearer. + public const string HeaderName = "X-Api-Key"; + + /// Bearer scheme prefix recognised in the Authorization header. + public const string BearerPrefix = "Bearer "; +} + +/// Custom claim types stamped on the principal authenticated by an API key. +public static class IntegrationClaimTypes +{ + /// Family the token belongs to (Guid string). + public const string FamilyId = "fn.familyId"; + + /// Member that authored the token (Guid string). + public const string AuthorMemberId = "fn.authorMemberId"; + + /// Token row id, useful for audit logging. + public const string ApiKeyId = "fn.apiKeyId"; +} + +/// Options for . Currently empty. +public sealed class IntegrationApiKeyAuthenticationOptions : AuthenticationSchemeOptions; + +/// +/// Authentication handler that resolves an integration API key from either +/// the Authorization: Bearer โ€ฆ or the X-Api-Key header. Endpoints +/// under /api/integrations/** opt in by declaring this scheme; cookie- +/// authenticated endpoints are unaffected because the default challenge stays +/// on Cookie/OIDC. +/// +public sealed class IntegrationApiKeyAuthenticationHandler + : AuthenticationHandler +{ + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public IntegrationApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + TimeProvider timeProvider) + : base(options, logger, encoder) + { + _timeProvider = timeProvider; + } + + /// + protected override async Task HandleAuthenticateAsync() + { + var token = ExtractToken(); + if (string.IsNullOrEmpty(token)) + { + // No header at all โ†’ leave the door open for the next scheme. + return AuthenticateResult.NoResult(); + } + + // Resolve the DbContext from the per-request scope. Constructor injection + // would tie the (singleton) handler factory to a scoped service. + var db = Context.RequestServices.GetRequiredService(); + + var hash = IntegrationTokens.Hash(token); + var key = await db.IntegrationApiKeys + .FirstOrDefaultAsync(k => k.TokenHash == hash, Context.RequestAborted); + + if (key is null || key.RevokedAt is not null) + { + // Generic message: do not hint whether the token existed and got + // revoked vs. never existed, to avoid leaking enumeration signal. + return AuthenticateResult.Fail("Invalid integration API key."); + } + + // Best-effort last-used update. Failing to write should not block auth + // โ€” a missed timestamp is preferable to a 401 storm during a DB blip. + try + { + key.LastUsedAt = _timeProvider.GetUtcNow(); + await db.SaveChangesAsync(Context.RequestAborted); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to update LastUsedAt for integration API key {KeyId}", key.Id); + } + + var identity = new ClaimsIdentity(Scheme.Name); + identity.AddClaim(new Claim(IntegrationClaimTypes.FamilyId, key.FamilyId.ToString())); + identity.AddClaim(new Claim(IntegrationClaimTypes.AuthorMemberId, key.AuthorMemberId.ToString())); + identity.AddClaim(new Claim(IntegrationClaimTypes.ApiKeyId, key.Id.ToString())); + identity.AddClaim(new Claim(ClaimTypes.Name, key.Name)); + // NameIdentifier doubles as the audit "actor" because that is the + // claim the HttpContextActorProvider falls back to. Prefixing keeps + // these rows visually distinct from human-user audit entries. + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, $"integration:{key.Name}")); + + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name); + return AuthenticateResult.Success(ticket); + } + + /// + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + // Match the standard 401 + WWW-Authenticate shape so curl / clients + // know which scheme they should be using. + Response.StatusCode = StatusCodes.Status401Unauthorized; + Response.Headers.WWWAuthenticate = $"{Scheme.Name} realm=\"FamilyNido\""; + return Task.CompletedTask; + } + + private string? ExtractToken() + { + // Header check order: explicit X-Api-Key first (the form HA's + // rest_command snippet uses), Authorization: Bearer second. + if (Request.Headers.TryGetValue(IntegrationApiKeyDefaults.HeaderName, out var apiKeyHeader)) + { + var raw = apiKeyHeader.ToString(); + if (!string.IsNullOrWhiteSpace(raw)) + { + return raw.Trim(); + } + } + + if (Request.Headers.TryGetValue(HeaderNames.Authorization, out var authHeader)) + { + var raw = authHeader.ToString(); + if (raw.StartsWith(IntegrationApiKeyDefaults.BearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return raw[IntegrationApiKeyDefaults.BearerPrefix.Length..].Trim(); + } + } + + return null; + } +} diff --git a/src/FamilyNido.Api/Features/Integrations/IntegrationApiKeyDto.cs b/src/FamilyNido.Api/Features/Integrations/IntegrationApiKeyDto.cs new file mode 100644 index 0000000..4aa30de --- /dev/null +++ b/src/FamilyNido.Api/Features/Integrations/IntegrationApiKeyDto.cs @@ -0,0 +1,29 @@ +namespace FamilyNido.Api.Features.Integrations; + +/// +/// Wire shape returned when listing existing integration API keys. The +/// plaintext token is intentionally absent โ€” once created it cannot be +/// recovered, callers must regenerate. +/// +/// Token row id. +/// Human-readable label. +/// Public-safe visual prefix (first chars of the secret). +/// When the token was created. +/// When the token was last accepted by the auth handler. Null while unused. +/// When the token was revoked. Null while active. +public sealed record IntegrationApiKeyDto( + Guid Id, + string Name, + string Prefix, + DateTimeOffset CreatedAt, + DateTimeOffset? LastUsedAt, + DateTimeOffset? RevokedAt); + +/// +/// Response of the create endpoint โ€” includes the freshly minted plaintext +/// token alongside the persisted projection. The plaintext is the only chance +/// the caller has to copy it to its target system. +/// +/// Plaintext secret. Show once, never again. +/// Persisted token metadata. +public sealed record CreatedIntegrationApiKeyDto(string Token, IntegrationApiKeyDto Key); diff --git a/src/FamilyNido.Api/Features/Integrations/IntegrationEndpoints.cs b/src/FamilyNido.Api/Features/Integrations/IntegrationEndpoints.cs new file mode 100644 index 0000000..0da33e1 --- /dev/null +++ b/src/FamilyNido.Api/Features/Integrations/IntegrationEndpoints.cs @@ -0,0 +1,67 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FluentValidation; + +namespace FamilyNido.Api.Features.Integrations; + +/// +/// Endpoints for admin-side management of integration API keys. The actual +/// machine-to-machine API surface (creating tasks, future endpoints) lives +/// under /api/v1/** in . +/// +public static class IntegrationEndpoints +{ + /// Registers /api/integrations/api-keys/** routes. + public static IEndpointRouteBuilder MapIntegrationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/integrations").WithTags("Integrations"); + + // โ”€โ”€โ”€ Admin token management (cookie session) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + group.MapGet("/api-keys", ListAsync) + .RequireAuthorization(Policies.Admin); + + group.MapPost("/api-keys", CreateAsync) + .RequireAuthorization(Policies.Admin); + + group.MapPost("/api-keys/{id:guid}/revoke", RevokeAsync) + .RequireAuthorization(Policies.Admin); + + return app; + } + + private static async Task ListAsync(IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new ListIntegrationApiKeys.Query(), ct); + return result.IsSuccess + ? Results.Ok(result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task CreateAsync( + CreateIntegrationApiKey.Command command, + IMediator mediator, + IValidator validator, + CancellationToken ct) + { + var problem = await validator.ValidateOrProblemAsync(command, ct); + if (problem is not null) + { + return problem; + } + + var result = await mediator.SendAsync(command, ct); + return result.IsSuccess + ? Results.Created($"/api/integrations/api-keys/{result.Value.Key.Id}", result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task RevokeAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new RevokeIntegrationApiKey.Command(id), ct); + return result.IsSuccess + ? Results.NoContent() + : result.Error.ToHttpResult(); + } +} diff --git a/src/FamilyNido.Api/Features/Integrations/IntegrationTokens.cs b/src/FamilyNido.Api/Features/Integrations/IntegrationTokens.cs new file mode 100644 index 0000000..de428c4 --- /dev/null +++ b/src/FamilyNido.Api/Features/Integrations/IntegrationTokens.cs @@ -0,0 +1,51 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace FamilyNido.Api.Features.Integrations; + +/// +/// Cryptographic helpers for the API-key plaintext / hash / prefix triplet. +/// All keys share a stable visual prefix so they are easy to grep for in +/// configuration files and logs. +/// +public static class IntegrationTokens +{ + /// Visual marker every plaintext token starts with. + public const string PlaintextPrefix = "bxn_"; + + /// + /// Number of leading characters preserved in clear text alongside the hash. + /// Long enough to disambiguate a few keys at a glance ("bxn_a1b2c3d4"), + /// short enough that an attacker who steals it cannot brute-force the rest. + /// + public const int PrefixDisplayLength = 12; + + /// + /// Generate a fresh random plaintext token. Returns the full secret โ€” + /// callers persist only the digest plus . + /// + public static string Generate() + { + // 32 bytes = 256 bits of entropy; base64url is URL-safe and fits + // headers without quoting headaches. + var randomBytes = RandomNumberGenerator.GetBytes(32); + return PlaintextPrefix + Base64UrlEncoder.Encode(randomBytes); + } + + /// + /// SHA-256 hex digest of . We do not need + /// PBKDF2/Argon2 here because the source material already carries 256 + /// bits of randomness โ€” slow hashing would only protect against weak + /// secrets, which by construction we never produce. + /// + public static string Hash(string plaintext) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(plaintext)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + /// Public-safe visual prefix used to identify a key in the UI. + public static string PrefixOf(string plaintext) + => plaintext.Length <= PrefixDisplayLength ? plaintext : plaintext[..PrefixDisplayLength]; +} diff --git a/src/FamilyNido.Api/Features/Integrations/ListIntegrationApiKeys.cs b/src/FamilyNido.Api/Features/Integrations/ListIntegrationApiKeys.cs new file mode 100644 index 0000000..56f3bc1 --- /dev/null +++ b/src/FamilyNido.Api/Features/Integrations/ListIntegrationApiKeys.cs @@ -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.Integrations; + +/// Slice for GET /api/integrations/api-keys โ€” admin lists tokens of their family. +public static class ListIntegrationApiKeys +{ + /// Empty query โ€” caller is the implicit subject (and must be admin). + public sealed record Query : IRequest>>; + + /// Reads the family's tokens, newest first. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task>> 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 rows = await _db.IntegrationApiKeys + .AsNoTracking() + .Where(k => k.FamilyId == current.Family.Id) + .OrderByDescending(k => k.CreatedAt) + .Select(k => new IntegrationApiKeyDto( + k.Id, + k.Name, + k.Prefix, + k.CreatedAt, + k.LastUsedAt, + k.RevokedAt)) + .ToListAsync(cancellationToken); + + return Result>.Success(rows); + } + } +} diff --git a/src/FamilyNido.Api/Features/Integrations/RevokeIntegrationApiKey.cs b/src/FamilyNido.Api/Features/Integrations/RevokeIntegrationApiKey.cs new file mode 100644 index 0000000..e296e86 --- /dev/null +++ b/src/FamilyNido.Api/Features/Integrations/RevokeIntegrationApiKey.cs @@ -0,0 +1,65 @@ +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.Integrations; + +/// +/// Slice for POST /api/integrations/api-keys/{id}/revoke. Soft-revokes +/// the token: the row stays for audit but the auth handler refuses to +/// authenticate it from this point on. +/// +public static class RevokeIntegrationApiKey +{ + /// Command identifying the token to revoke. + public sealed record Command(Guid Id) : IRequest>; + + /// Marks the token as revoked, idempotent on already-revoked rows. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider timeProvider) + { + _db = db; + _userContext = userContext; + _timeProvider = timeProvider; + } + + /// + public async Task> 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 key = await _db.IntegrationApiKeys + .FirstOrDefaultAsync( + k => k.Id == request.Id && k.FamilyId == current.Family.Id, + cancellationToken); + + if (key is null) + { + return ApplicationError.NotFound("integration_api_key.not_found", "Token not found."); + } + + // Idempotent: revoking an already-revoked token is a no-op success + // so the UI can let users hammer the button without surprises. + if (key.RevokedAt is null) + { + key.RevokedAt = _timeProvider.GetUtcNow(); + await _db.SaveChangesAsync(cancellationToken); + } + + return Unit.Value; + } + } +} diff --git a/src/FamilyNido.Api/Features/Invitations/AcceptInvitationLocal.cs b/src/FamilyNido.Api/Features/Invitations/AcceptInvitationLocal.cs new file mode 100644 index 0000000..8903370 --- /dev/null +++ b/src/FamilyNido.Api/Features/Invitations/AcceptInvitationLocal.cs @@ -0,0 +1,179 @@ +using System.Security.Claims; +using FamilyNido.Api.Features.Auth; +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.Invitations; + +/// +/// Slice: an anonymous caller accepts an invitation by setting a local +/// password (no OIDC required). Atomically: validates the token, creates a +/// + a Local , links the +/// target , signs the cookie, and +/// returns the new user id. +/// +public static class AcceptInvitationLocal +{ + /// Command. + /// Raw invitation token from the URL. + /// Plain-text password chosen by the recipient. + public sealed record Command(string Token, string Password) : IRequest>; + + /// Validator. Reuses the central password policy. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Token).NotEmpty(); + RuleFor(x => x.Password).Password(); + } + } + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly IPasswordHasher _passwordHasher; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + IPasswordHasher passwordHasher, + IHttpContextAccessor httpContextAccessor, + TimeProvider timeProvider) + { + _db = db; + _passwordHasher = passwordHasher; + _httpContextAccessor = httpContextAccessor; + _timeProvider = timeProvider; + } + + /// + public async Task> HandleAsync(Command request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Token)) + { + return ApplicationError.NotFound("invitation.not_found", "Invitation not found."); + } + + var hash = InvitationToken.Hash(request.Token); + var invitation = await _db.Invitations + .FirstOrDefaultAsync(i => i.TokenHash == hash, ct); + + if (invitation is null) + { + return ApplicationError.NotFound("invitation.not_found", "Invitation not found."); + } + + // The handler is "anonymous" so it must own the whole transaction + // explicitly: token consumption, user creation, member linking + // and cookie sign-in either all succeed or none does. Postgres + // gives us serializable-by-default semantics here through EF. + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + var now = _timeProvider.GetUtcNow(); + var consumedRows = await _db.Invitations + .Where(i => i.Id == invitation.Id + && i.ConsumedAt == null + && i.RevokedAt == null + && i.ExpiresAt > now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(i => i.ConsumedAt, now), ct); + + if (consumedRows == 0) + { + return ApplicationError.Conflict( + "invitation.unavailable", + "This invitation has already been used, expired, or was revoked."); + } + + // If a user with that email already exists, refuse: this path is + // for *new* accounts. Existing users must accept-oidc (after OIDC + // login) or set a password from "Mi cuenta". + var emailExists = await _db.Users.AnyAsync(u => u.Email.ToLower() == invitation.Email, ct); + if (emailExists) + { + return ApplicationError.Conflict( + "user.email_already_registered", + "An account with this email already exists. Use the matching login method or set a password from your account screen."); + } + + var member = await _db.FamilyMembers.FirstOrDefaultAsync(m => m.Id == invitation.FamilyMemberId, ct); + if (member is null || member.UserId is not null) + { + return ApplicationError.Conflict( + "family_member.already_linked", + "Target member is already linked to another user."); + } + + // Bootstrap the new identity: User with the role configured at + // invitation time + a single Local credential. + var user = new User + { + Email = invitation.Email, + DisplayName = member.DisplayName, + Role = invitation.RoleOnAccept, + LastLoginAt = now, + }; + _db.Users.Add(user); + + var hashed = _passwordHasher.HashPassword(user, request.Password); + _db.UserCredentials.Add(new UserCredential + { + UserId = user.Id, + User = user, + Provider = IdentityProvider.Local, + PasswordHash = hashed, + LastUsedAt = now, + }); + + // Bind the member to the brand-new user and stamp the + // ConsumedByUserId now that we know the id. + member.UserId = user.Id; + await _db.Invitations + .Where(i => i.Id == invitation.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(i => i.ConsumedByUserId, (Guid?)user.Id), ct); + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + + // Sign the cookie now so the caller is logged in upon return. + var http = _httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("AcceptInvitationLocal requires an HTTP context."); + await SignInAsync(http, user); + + return new AcceptInvitationResponse( + FamilyMemberId: invitation.FamilyMemberId, + FamilyId: invitation.FamilyId, + Role: invitation.RoleOnAccept); + } + + private static async Task SignInAsync(HttpContext http, User user) + { + var claims = new List + { + 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); + } + } +} diff --git a/src/FamilyNido.Api/Features/Invitations/AcceptInvitationOidc.cs b/src/FamilyNido.Api/Features/Invitations/AcceptInvitationOidc.cs new file mode 100644 index 0000000..b96b4a9 --- /dev/null +++ b/src/FamilyNido.Api/Features/Invitations/AcceptInvitationOidc.cs @@ -0,0 +1,137 @@ +using FamilyNido.Api.Features.Auth; +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.Invitations; + +/// +/// Slice: an authenticated (already-OIDC-logged) caller redeems an invitation +/// to be linked to a . The redemption +/// is performed with a conditional UPDATE so two concurrent clicks cannot +/// consume the same token twice. +/// +public static class AcceptInvitationOidc +{ + /// Command carrying the raw token from the URL. + public sealed record Command(string Token) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider timeProvider) + { + _db = db; + _userContext = userContext; + _timeProvider = timeProvider; + } + + /// + public async Task> HandleAsync(Command request, CancellationToken ct) + { + var user = await _userContext.GetUserAsync(ct); + if (user is null) + { + return ApplicationError.Forbidden("auth.unauthenticated", "Caller is not authenticated."); + } + + // The user might already be linked to another family member โ€” + // refuse rather than steal the existing binding silently. + var alreadyLinked = await _db.FamilyMembers.AnyAsync(m => m.UserId == user.Id, ct); + if (alreadyLinked) + { + return ApplicationError.Conflict( + "user.already_linked", + "Your account is already linked to a family member."); + } + + if (string.IsNullOrWhiteSpace(request.Token)) + { + return ApplicationError.NotFound("invitation.not_found", "Invitation not found."); + } + + var hash = InvitationToken.Hash(request.Token); + var invitation = await _db.Invitations + .FirstOrDefaultAsync(i => i.TokenHash == hash, ct); + + if (invitation is null) + { + return ApplicationError.NotFound("invitation.not_found", "Invitation not found."); + } + + // Conditional update: only consume the row if it's still pending. + // We piggyback on EF's optimistic update by checking ConsumedAt / + // RevokedAt / ExpiresAt within the WHERE (translated to SQL) and + // verifying the affected row count. + var now = _timeProvider.GetUtcNow(); + var consumedRows = await _db.Invitations + .Where(i => i.Id == invitation.Id + && i.ConsumedAt == null + && i.RevokedAt == null + && i.ExpiresAt > now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(i => i.ConsumedAt, now) + .SetProperty(i => i.ConsumedByUserId, user.Id), ct); + + if (consumedRows == 0) + { + return ApplicationError.Conflict( + "invitation.unavailable", + "This invitation has already been used, expired, or was revoked."); + } + + // Bind member โ†’ user (final step). If a stray race had bound the + // member to someone else between our read and write, the + // single-row update with the strict predicate below catches it. + var memberRows = await _db.FamilyMembers + .Where(m => m.Id == invitation.FamilyMemberId && m.UserId == null) + .ExecuteUpdateAsync(setters => setters + .SetProperty(m => m.UserId, (Guid?)user.Id), ct); + + if (memberRows == 0) + { + // Compensate: roll back the consumed flag so the admin can + // reissue. ExecuteUpdate is auto-committed so we can't undo + // via transaction here โ€” settle for clearing ConsumedAt. + await _db.Invitations + .Where(i => i.Id == invitation.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(i => i.ConsumedAt, (DateTimeOffset?)null) + .SetProperty(i => i.ConsumedByUserId, (Guid?)null), ct); + + return ApplicationError.Conflict( + "family_member.already_linked", + "Target member is already linked to another user. Ask the admin to reissue the invitation."); + } + + // Promote the user to the role configured at invitation time. + // Adult covers the common case; Admin grants the management UI. + var trackedUser = await _db.Users.FirstAsync(u => u.Id == user.Id, ct); + trackedUser.Role = invitation.RoleOnAccept; + trackedUser.LastLoginAt = now; + await _db.SaveChangesAsync(ct); + + return new AcceptInvitationResponse( + FamilyMemberId: invitation.FamilyMemberId, + FamilyId: invitation.FamilyId, + Role: invitation.RoleOnAccept); + } + } +} + +/// Outcome of a successful invitation redemption. +/// Member the user is now linked to. +/// Owning family of that member. +/// Role granted to the user. +public sealed record AcceptInvitationResponse( + Guid FamilyMemberId, + Guid FamilyId, + Domain.Families.FamilyRole Role); diff --git a/src/FamilyNido.Api/Features/Invitations/CreateInvitation.cs b/src/FamilyNido.Api/Features/Invitations/CreateInvitation.cs new file mode 100644 index 0000000..adf71cc --- /dev/null +++ b/src/FamilyNido.Api/Features/Invitations/CreateInvitation.cs @@ -0,0 +1,270 @@ +using System.Text.RegularExpressions; +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Features.Notifications; +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.Domain.Identity; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FamilyNido.Api.Features.Invitations; + +/// +/// Slice: creates a one-time invitation token for a family member, optionally +/// creating the member in the same transaction. Sends the invitation email +/// best-effort and always returns the absolute "copy link" so the admin can +/// fall back to chat/whatsapp delivery if SMTP is unavailable. +/// +public static partial class CreateInvitation +{ + [GeneratedRegex("^#[0-9a-fA-F]{6}$", RegexOptions.Compiled)] + private static partial Regex HexColorRegex(); + + /// + /// Command. Either reference an existing (the + /// member becomes the invitation target) or supply the four "new member" + /// fields and let the handler create it inline. + /// + /// Existing member to bind. When null, a new member is created. + /// Display name for the new member (required when is null). + /// Kind of member for the new row (required when creating; must be Adult โ€” only adults authenticate). + /// Hex color for the new member. + /// Optional date of birth for the new member. + /// Recipient email โ€” also persisted on the new member as ContactEmail. + /// Role the user receives upon acceptance. Adult or Admin. + public sealed record Command( + Guid? MemberId, + string? DisplayName, + MemberType? MemberType, + string? ColorHex, + DateOnly? BirthDate, + string Email, + FamilyRole RoleOnAccept) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Email) + .NotEmpty() + .EmailAddress() + .MaximumLength(254); + + RuleFor(x => x.RoleOnAccept) + .Must(r => r == FamilyRole.Adult || r == FamilyRole.Admin) + .WithMessage("RoleOnAccept must be Adult or Admin."); + + // When the caller wants to create a new member inline, all four + // member-creation fields must be coherent. + When(x => x.MemberId is null, () => + { + RuleFor(x => x.DisplayName) + .NotEmpty() + .MaximumLength(120); + + RuleFor(x => x.MemberType) + .NotNull() + .Must(t => t == Domain.Families.MemberType.Adult) + .WithMessage("Only adult members can be invited."); + + RuleFor(x => x.ColorHex) + .NotEmpty() + .Matches(HexColorRegex()) + .WithMessage("ColorHex must be in #RRGGBB format."); + + RuleFor(x => x.BirthDate) + .Must(d => d is null || d.Value <= DateOnly.FromDateTime(DateTime.UtcNow)) + .WithMessage("BirthDate cannot be in the future."); + }); + } + } + + /// Handler โ€” persists the invitation and dispatches the email. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly IEmailSender _emailSender; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly EmailOptions _emailOptions; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + IEmailSender emailSender, + TimeProvider timeProvider, + IOptions emailOptions, + ILogger logger) + { + _db = db; + _userContext = userContext; + _emailSender = emailSender; + _timeProvider = timeProvider; + _logger = logger; + _emailOptions = emailOptions.Value; + } + + /// + public async Task> 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 normalizedEmail = request.Email.Trim().ToLowerInvariant(); + + // Resolve the target member: existing or just-created. + FamilyMember member; + if (request.MemberId is { } memberId) + { + var existing = await _db.FamilyMembers + .FirstOrDefaultAsync(m => m.Id == memberId && m.FamilyId == current.Family.Id, ct); + if (existing is null) + { + return ApplicationError.NotFound("family_member.not_found", $"Member {memberId} not found."); + } + + if (existing.MemberType != Domain.Families.MemberType.Adult) + { + return ApplicationError.Validation( + "invitation.member_not_adult", + "Only adult members can be invited."); + } + + if (existing.UserId is not null) + { + return ApplicationError.Conflict( + "family_member.already_linked", + "This member is already linked to a user."); + } + + member = existing; + } + else + { + member = new FamilyMember + { + FamilyId = current.Family.Id, + DisplayName = request.DisplayName!, + MemberType = request.MemberType!.Value, + ColorHex = request.ColorHex!, + BirthDate = request.BirthDate, + ContactEmail = normalizedEmail, + }; + _db.FamilyMembers.Add(member); + } + + // Single-use token: only the hash lives in the DB; the raw token + // ships in the email and the response (so admins can copy the + // link if SMTP is down). + var rawToken = InvitationToken.GenerateRaw(); + var tokenHash = InvitationToken.Hash(rawToken); + var now = _timeProvider.GetUtcNow(); + + var invitation = new Invitation + { + FamilyId = current.Family.Id, + FamilyMemberId = member.Id, + FamilyMember = member, + Email = normalizedEmail, + RoleOnAccept = request.RoleOnAccept, + TokenHash = tokenHash, + ExpiresAt = now + _emailOptions.InvitationLifetime, + }; + _db.Invitations.Add(invitation); + + await _db.SaveChangesAsync(ct); + + // Build the absolute link from configured AppBaseUrl. We rely on + // the operator to keep the URL aligned with the front-facing + // domain โ€” there is no good way to derive it from the request + // (the API may live behind several reverse proxies). The path + // matches the Angular route `/invite/:token` (renamed from + // `/invitar/` when we Englishified the routes). + var copyLink = $"{_emailOptions.AppBaseUrl.TrimEnd('/')}/invite/{rawToken}"; + + // Best-effort email. Even if delivery fails we keep the + // invitation alive โ€” the admin can copy the link from the + // response and pass it manually. + var subject = $"Te han invitado a la familia {current.Family.Name} en FamilyNido"; + var html = RenderHtml(current.Family.Name, current.User.DisplayName, member.DisplayName, copyLink, invitation.ExpiresAt); + EmailResult emailResult; + try + { + emailResult = await _emailSender.SendAsync(new EmailMessage(normalizedEmail, subject, html), ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Email send unexpectedly threw for invitation {InvitationId}", invitation.Id); + emailResult = new EmailResult(false, null, "exception"); + } + + var dto = ToDto(invitation, member.DisplayName); + return new CreateInvitationResponse( + Invitation: dto, + MemberId: member.Id, + CopyLink: copyLink, + EmailDelivered: emailResult.Delivered); + } + + private static InvitationDto ToDto(Invitation i, string memberDisplayName) => new( + Id: i.Id, + FamilyMemberId: i.FamilyMemberId, + MemberDisplayName: memberDisplayName, + Email: i.Email, + RoleOnAccept: i.RoleOnAccept, + ExpiresAt: i.ExpiresAt, + CreatedAt: i.CreatedAt); + + private static string RenderHtml( + string familyName, + string inviterName, + string memberDisplayName, + string copyLink, + DateTimeOffset expiresAt) + { + // Keep it minimal: a single paragraph, the link as a button-like + // anchor, and an expiration line. No external assets. + var safeFamily = WebUtility(familyName); + var safeInviter = WebUtility(inviterName); + var safeMember = WebUtility(memberDisplayName); + var safeLink = WebUtility(copyLink); + var expires = expiresAt.ToString("d MMMM yyyy"); + + return $$""" + + +

Hola {{safeMember}},

+

{{safeInviter}} te ha invitado a unirte a la familia {{safeFamily}} en FamilyNido.

+

+ + Aceptar invitaciรณn + +

+

+ O copia y pega este enlace en tu navegador:
+ {{safeLink}} +

+

El enlace caduca el {{expires}}.

+ + + """; + } + + // Tiny escape for the small set of fields we interpolate. Avoids + // pulling in a full HTML library for one email template. + private static string WebUtility(string s) => System.Net.WebUtility.HtmlEncode(s); + } +} diff --git a/src/FamilyNido.Api/Features/Invitations/GetInvitationByToken.cs b/src/FamilyNido.Api/Features/Invitations/GetInvitationByToken.cs new file mode 100644 index 0000000..2138509 --- /dev/null +++ b/src/FamilyNido.Api/Features/Invitations/GetInvitationByToken.cs @@ -0,0 +1,121 @@ +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.Invitations; + +/// +/// Slice: anonymous read of an invitation by its raw token. Used by the +/// "/invite/:token" preview screen so the recipient sees who is inviting +/// them and to which family before deciding whether to log in or set a +/// password. Never reveals the recipient email or any other sensitive bit. +/// +public static class GetInvitationByToken +{ + /// Query carrying the raw token string. + public sealed record Query(string Token) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler(ApplicationDbContext db, TimeProvider timeProvider) + { + _db = db; + _timeProvider = timeProvider; + } + + /// + public async Task> HandleAsync(Query request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Token)) + { + return ApplicationError.NotFound("invitation.not_found", "Invitation not found."); + } + + var hash = InvitationToken.Hash(request.Token); + + // Pull the invitation along with the bits the preview screen + // needs. Single SQL round-trip; we don't filter by lifecycle here + // because the front wants to show "already used" / "expired" + // instead of a generic 404 to signal what happened. + var preview = await _db.Invitations + .AsNoTracking() + .Where(i => i.TokenHash == hash) + .Select(i => new + { + i.Id, + i.CreatedBy, + i.ConsumedAt, + i.RevokedAt, + i.ExpiresAt, + FamilyName = i.FamilyMember!.Family!.Name, + MemberDisplayName = i.FamilyMember!.DisplayName, + }) + .FirstOrDefaultAsync(ct); + + if (preview is null) + { + return ApplicationError.NotFound("invitation.not_found", "Invitation not found."); + } + + // CreatedBy stores the inviter's user-id (or "system" for + // bootstrapped rows). Resolve to a display name when possible โ€” + // null is fine; the front falls back to "alguien te ha invitado". + string? inviterName = null; + if (Guid.TryParse(preview.CreatedBy, out var inviterId)) + { + inviterName = await _db.Users + .AsNoTracking() + .Where(u => u.Id == inviterId) + .Select(u => u.DisplayName) + .FirstOrDefaultAsync(ct); + } + + var now = _timeProvider.GetUtcNow(); + var status = + preview.RevokedAt is not null ? InvitationStatus.Revoked + : preview.ConsumedAt is not null ? InvitationStatus.Consumed + : preview.ExpiresAt <= now ? InvitationStatus.Expired + : InvitationStatus.Pending; + + return new InvitationPreviewDto( + FamilyName: preview.FamilyName, + MemberDisplayName: preview.MemberDisplayName, + InviterDisplayName: inviterName, + ExpiresAt: preview.ExpiresAt, + Status: status); + } + } +} + +/// Compact preview returned to anonymous callers on the /invite/:token screen. +/// Display name of the inviting family. +/// Member that will be linked. +/// Best-effort display name of the admin who created the invitation. Null when CreatedBy is not parseable as a user id. +/// UTC expiration instant. +/// Lifecycle bucket โ€” front decides which message to show. +public sealed record InvitationPreviewDto( + string FamilyName, + string MemberDisplayName, + string? InviterDisplayName, + DateTimeOffset ExpiresAt, + InvitationStatus Status); + +/// Lifecycle bucket of an invitation as seen from the preview endpoint. +public enum InvitationStatus +{ + /// Live and redeemable. + Pending = 0, + /// Already redeemed by some user. + Consumed = 1, + /// Manually revoked by an admin. + Revoked = 2, + /// Expired by reaching its TTL without being redeemed. + Expired = 3, +} diff --git a/src/FamilyNido.Api/Features/Invitations/InvitationDto.cs b/src/FamilyNido.Api/Features/Invitations/InvitationDto.cs new file mode 100644 index 0000000..800fc59 --- /dev/null +++ b/src/FamilyNido.Api/Features/Invitations/InvitationDto.cs @@ -0,0 +1,48 @@ +using FamilyNido.Domain.Families; + +namespace FamilyNido.Api.Features.Invitations; + +/// +/// Read model returned by admin endpoints. Never exposes the raw token โ€” +/// "copy link" UX uses the link returned by +/// at creation time only. +/// +/// Stable invitation id. +/// Member that will be linked. +/// Display name of the target member. +/// Recipient email. +/// Role granted when accepted. +/// UTC instant after which the token is unusable. +/// UTC instant of creation. +public sealed record InvitationDto( + Guid Id, + Guid FamilyMemberId, + string MemberDisplayName, + string Email, + FamilyRole RoleOnAccept, + DateTimeOffset ExpiresAt, + DateTimeOffset CreatedAt); + +/// +/// Compact invitation summary embedded in +/// so the roster screen can render "pending: dan@..." without a second +/// roundtrip per member. +/// +/// Invitation id. +/// Recipient email. +/// UTC expiration instant. +public sealed record PendingInvitationDto(Guid Id, string Email, DateTimeOffset ExpiresAt); + +/// +/// Response of POST /api/invitations. Carries the freshly minted link +/// so the admin can copy it manually if email delivery failed. +/// +/// Persisted invitation read model. +/// Linked family member id (existing or just created). +/// Absolute URL the recipient must visit to accept. +/// True when the SMTP relay accepted the message. +public sealed record CreateInvitationResponse( + InvitationDto Invitation, + Guid MemberId, + string CopyLink, + bool EmailDelivered); diff --git a/src/FamilyNido.Api/Features/Invitations/InvitationEndpoints.cs b/src/FamilyNido.Api/Features/Invitations/InvitationEndpoints.cs new file mode 100644 index 0000000..bbf2258 --- /dev/null +++ b/src/FamilyNido.Api/Features/Invitations/InvitationEndpoints.cs @@ -0,0 +1,115 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; +using FluentValidation; + +namespace FamilyNido.Api.Features.Invitations; + +/// REST endpoints for invitations (RF-AUTH-003). +public static class InvitationEndpoints +{ + /// Registers /api/invitations endpoints on the given route group. + public static IEndpointRouteBuilder MapInvitationEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/invitations") + .WithTags("Invitations") + .RequireAuthorization(Policies.Admin); + + admin.MapGet("/", ListAsync); + admin.MapPost("/", CreateAsync); + admin.MapPost("/{id:guid}/revoke", RevokeAsync); + + // Preview + accept-local are anonymous so the recipient can see who + // is inviting them and onboard with a brand-new password โ€” no + // PocketID account required. + var anon = app.MapGroup("/api/invitations") + .WithTags("Invitations"); + anon.MapGet("/{token}", PreviewAsync).AllowAnonymous(); + anon.MapPost("/{token}/accept-local", AcceptLocalAsync).AllowAnonymous(); + + // Accept-oidc requires authentication: any logged-in user (including + // freshly-orphan ones whose only role is Guest) can claim the + // invitation. The handler enforces "user not already linked". + var redeem = app.MapGroup("/api/invitations") + .WithTags("Invitations") + .RequireAuthorization(Policies.AuthenticatedUser); + redeem.MapPost("/{token}/accept-oidc", AcceptOidcAsync); + + return app; + } + + private static async Task ListAsync(IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new ListInvitations.Query(), ct); + return result.IsSuccess + ? Results.Ok(result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task CreateAsync( + CreateInvitation.Command command, + IValidator 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/invitations/{result.Value.Invitation.Id}", result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task RevokeAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new RevokeInvitation.Command(id), ct); + return result.IsSuccess + ? Results.NoContent() + : result.Error.ToHttpResult(); + } + + private static async Task PreviewAsync(string token, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new GetInvitationByToken.Query(token), ct); + return result.IsSuccess + ? Results.Ok(result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task AcceptOidcAsync(string token, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new AcceptInvitationOidc.Command(token), ct); + return result.IsSuccess + ? Results.Ok(result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task AcceptLocalAsync( + string token, + AcceptLocalBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new AcceptInvitationLocal.Command(token, body.Password); + + 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(); + } +} + +/// Wire payload for POST /api/invitations/{token}/accept-local. +/// Password the recipient chose for their new account. +public sealed record AcceptLocalBody(string Password); diff --git a/src/FamilyNido.Api/Features/Invitations/InvitationToken.cs b/src/FamilyNido.Api/Features/Invitations/InvitationToken.cs new file mode 100644 index 0000000..4659548 --- /dev/null +++ b/src/FamilyNido.Api/Features/Invitations/InvitationToken.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography; +using System.Text; + +namespace FamilyNido.Api.Features.Invitations; + +/// +/// Helpers to generate and hash invitation tokens. The raw token only ever +/// lives on the wire (email + URL); the database stores its SHA-256 so a +/// leaked dump cannot replay pending invitations. +/// +internal static class InvitationToken +{ + /// Length in bytes of the random material used to build a token. + private const int RawByteLength = 32; + + /// + /// Generates a fresh, URL-safe token. 32 bytes of randomness encoded with + /// base64url is well over the security threshold for a one-time use token. + /// + public static string GenerateRaw() + { + Span bytes = stackalloc byte[RawByteLength]; + RandomNumberGenerator.Fill(bytes); + return Base64UrlEncode(bytes); + } + + /// Computes the SHA-256 of the raw token. + public static byte[] Hash(string rawToken) + { + var bytes = Encoding.UTF8.GetBytes(rawToken); + return SHA256.HashData(bytes); + } + + private static string Base64UrlEncode(ReadOnlySpan bytes) + { + var s = Convert.ToBase64String(bytes); + // Standard "URL-safe base64" without padding. + return s.Replace('+', '-').Replace('/', '_').TrimEnd('='); + } +} diff --git a/src/FamilyNido.Api/Features/Invitations/ListInvitations.cs b/src/FamilyNido.Api/Features/Invitations/ListInvitations.cs new file mode 100644 index 0000000..038c60a --- /dev/null +++ b/src/FamilyNido.Api/Features/Invitations/ListInvitations.cs @@ -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.Invitations; + +/// +/// Slice: lists pending invitations (not consumed, not revoked, not expired) +/// in the caller's family. Used by the admin "pending invitations" panel. +/// +public static class ListInvitations +{ + /// Query โ€” no inputs; the caller's family is implicit. + public sealed record Query : IRequest>>; + + /// Handler. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider timeProvider) + { + _db = db; + _userContext = userContext; + _timeProvider = timeProvider; + } + + /// + public async Task>> HandleAsync(Query 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 now = _timeProvider.GetUtcNow(); + + var rows = await _db.Invitations + .AsNoTracking() + .Include(i => i.FamilyMember) + .Where(i => i.FamilyId == current.Family.Id + && i.ConsumedAt == null + && i.RevokedAt == null + && i.ExpiresAt > now) + .OrderByDescending(i => i.CreatedAt) + .ToListAsync(ct); + + IReadOnlyList dto = [.. rows.Select(i => new InvitationDto( + Id: i.Id, + FamilyMemberId: i.FamilyMemberId, + MemberDisplayName: i.FamilyMember?.DisplayName ?? "", + Email: i.Email, + RoleOnAccept: i.RoleOnAccept, + ExpiresAt: i.ExpiresAt, + CreatedAt: i.CreatedAt))]; + + return Result>.Success(dto); + } + } +} diff --git a/src/FamilyNido.Api/Features/Invitations/RevokeInvitation.cs b/src/FamilyNido.Api/Features/Invitations/RevokeInvitation.cs new file mode 100644 index 0000000..5c25100 --- /dev/null +++ b/src/FamilyNido.Api/Features/Invitations/RevokeInvitation.cs @@ -0,0 +1,71 @@ +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.Invitations; + +/// +/// Slice: marks an invitation as revoked. Idempotent on already-revoked rows; +/// returns 409 on already-consumed ones (a consumed invitation cannot be +/// "un-redeemed"). +/// +public static class RevokeInvitation +{ + /// Command carrying the invitation id. + /// Identifier of the invitation to revoke. + public sealed record Command(Guid InvitationId) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider timeProvider) + { + _db = db; + _userContext = userContext; + _timeProvider = timeProvider; + } + + /// + public async Task> 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 invitation = await _db.Invitations + .FirstOrDefaultAsync( + i => i.Id == request.InvitationId && i.FamilyId == current.Family.Id, + ct); + + if (invitation is null) + { + return ApplicationError.NotFound("invitation.not_found", "Invitation does not exist."); + } + + if (invitation.ConsumedAt is not null) + { + return ApplicationError.Conflict( + "invitation.already_consumed", + "Cannot revoke an invitation that has already been consumed."); + } + + if (invitation.RevokedAt is null) + { + invitation.RevokedAt = _timeProvider.GetUtcNow(); + await _db.SaveChangesAsync(ct); + } + + return Unit.Value; + } + } +} diff --git a/src/FamilyNido.Api/Features/Meals/ClearMealSlot.cs b/src/FamilyNido.Api/Features/Meals/ClearMealSlot.cs new file mode 100644 index 0000000..cbd0b2c --- /dev/null +++ b/src/FamilyNido.Api/Features/Meals/ClearMealSlot.cs @@ -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.Domain.Meals; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Meals; + +/// +/// Slice: clear a single course of a meal slot. Idempotent โ€” a missing row or +/// already-null column is success. If, after clearing, both courses end up null, +/// the row itself is deleted to keep the table from holding tombstones. +/// +public static class ClearMealSlot +{ + /// Command โ€” coordinates the course to clear. + public sealed record Command(DateOnly Date, MealSlot Slot, MealCourse Course) : IRequest>; + + /// Carries no value; signals "completed" to the endpoint. + public sealed record Unit; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> HandleAsync(Command request, CancellationToken cancellationToken) + { + var current = await _userContext.GetAsync(cancellationToken); + if (current is null) + { + return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller."); + } + + var existing = await _db.MealPlanSlots + .FirstOrDefaultAsync( + s => s.FamilyId == current.Family.Id && + s.Date == request.Date && + s.Slot == request.Slot, + cancellationToken); + + if (existing is null) + { + return new Unit(); + } + + if (request.Course == MealCourse.First) + { + existing.FirstCourse = null; + } + else + { + existing.SecondCourse = null; + } + + // Drop the row entirely when both courses are gone so the table + // doesn't accumulate empty tombstones. + if (existing.FirstCourse is null && existing.SecondCourse is null) + { + _db.MealPlanSlots.Remove(existing); + } + + await _db.SaveChangesAsync(cancellationToken); + return new Unit(); + } + } +} diff --git a/src/FamilyNido.Api/Features/Meals/DuplicatePreviousWeek.cs b/src/FamilyNido.Api/Features/Meals/DuplicatePreviousWeek.cs new file mode 100644 index 0000000..dcb525b --- /dev/null +++ b/src/FamilyNido.Api/Features/Meals/DuplicatePreviousWeek.cs @@ -0,0 +1,113 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Meals; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Meals; + +/// +/// Slice: copy the previous week's slots into the target week. By default only +/// fills slots that are empty in the destination week; Overwrite=true +/// replaces non-empty slots too. Returns the resulting week as a single round-trip. +/// +public static class DuplicatePreviousWeek +{ + /// Command โ€” week to populate + overwrite policy. + /// Any date inside the target week (snapped to Monday). + /// When true, also replaces destination slots that already had a name. + public sealed record Command(DateOnly WeekStart, bool Overwrite) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly IMediator _mediator; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, IMediator mediator) + { + _db = db; + _userContext = userContext; + _mediator = mediator; + } + + /// + public async Task> HandleAsync(Command request, CancellationToken cancellationToken) + { + var current = await _userContext.GetAsync(cancellationToken); + if (current is null) + { + return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller."); + } + + var targetMonday = WeekStart.SnapToMonday(request.WeekStart); + var sourceMonday = targetMonday.AddDays(-7); + var sourceSunday = targetMonday.AddDays(-1); + var targetSunday = targetMonday.AddDays(6); + + var sourceRows = await _db.MealPlanSlots + .AsNoTracking() + .Where(s => s.FamilyId == current.Family.Id && + s.Date >= sourceMonday && s.Date <= sourceSunday) + .ToListAsync(cancellationToken); + + if (sourceRows.Count == 0) + { + // Nothing to copy โ€” short-circuit and return the (likely empty) week. + return await _mediator.SendAsync(new GetWeekPlan.Query(targetMonday), cancellationToken); + } + + var existingTarget = await _db.MealPlanSlots + .Where(s => s.FamilyId == current.Family.Id && + s.Date >= targetMonday && s.Date <= targetSunday) + .ToListAsync(cancellationToken); + + var existingByKey = existingTarget.ToDictionary(s => (s.Date, s.Slot)); + + foreach (var source in sourceRows) + { + var targetDate = source.Date.AddDays(7); + var key = (targetDate, source.Slot); + + if (existingByKey.TryGetValue(key, out var destination)) + { + // Per-course merge: each course is treated independently so we + // never half-replace a slot. + if (request.Overwrite || destination.FirstCourse is null) + { + if (source.FirstCourse is not null) + { + destination.FirstCourse = source.FirstCourse; + } + } + if (request.Overwrite || destination.SecondCourse is null) + { + if (source.SecondCourse is not null) + { + destination.SecondCourse = source.SecondCourse; + } + } + } + else + { + _db.MealPlanSlots.Add(new MealPlanSlot + { + FamilyId = current.Family.Id, + Date = targetDate, + Slot = source.Slot, + FirstCourse = source.FirstCourse, + SecondCourse = source.SecondCourse, + }); + } + } + + await _db.SaveChangesAsync(cancellationToken); + + return await _mediator.SendAsync(new GetWeekPlan.Query(targetMonday), cancellationToken); + } + } +} diff --git a/src/FamilyNido.Api/Features/Meals/GetMealSuggestions.cs b/src/FamilyNido.Api/Features/Meals/GetMealSuggestions.cs new file mode 100644 index 0000000..4fee31f --- /dev/null +++ b/src/FamilyNido.Api/Features/Meals/GetMealSuggestions.cs @@ -0,0 +1,100 @@ +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.Meals; + +/// +/// Slice: autocomplete suggestions from the family's meal-name history. Reads +/// the two course columns with two parallel SQL queries (each one filtered +/// case-insensitively in the database via ILIKE) and merges + dedupes them in +/// memory. Volume is small enough โ€” even a year of history is ~100 rows โ€” that +/// the in-memory step is cheaper than a tricky UNION/GROUP BY translation in EF. +/// +public static class GetMealSuggestions +{ + /// Default cap on results returned to the UI. + public const int DefaultLimit = 8; + + /// Hard upper bound to avoid pathological queries. + public const int MaxLimit = 50; + + /// Query โ€” empty prefix returns the most recent meal names overall. + public sealed record Query(string? Prefix, int? Limit) : IRequest>>; + + /// Handler. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task>> HandleAsync(Query request, CancellationToken cancellationToken) + { + var current = await _userContext.GetAsync(cancellationToken); + if (current is null) + { + return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller."); + } + + var limit = Math.Clamp(request.Limit ?? DefaultLimit, 1, MaxLimit); + var prefix = (request.Prefix ?? string.Empty).Trim(); + var pattern = prefix.Length == 0 ? null : EscapeLikePattern(prefix) + "%"; + + var familyId = current.Family.Id; + + // Two parallel-shaped queries โ€” each pulls (name, lastUsed) for one + // course column. Filter happens in SQL via ILIKE so the underlying + // (family_id, first_course) and (family_id, second_course) indexes + // can be used. + var firsts = await _db.MealPlanSlots + .AsNoTracking() + .Where(s => s.FamilyId == familyId && + s.FirstCourse != null && + (pattern == null || EF.Functions.ILike(s.FirstCourse!, pattern))) + .Select(s => new { Name = s.FirstCourse!, LastUsed = s.UpdatedAt ?? s.CreatedAt }) + .ToListAsync(cancellationToken); + + var seconds = await _db.MealPlanSlots + .AsNoTracking() + .Where(s => s.FamilyId == familyId && + s.SecondCourse != null && + (pattern == null || EF.Functions.ILike(s.SecondCourse!, pattern))) + .Select(s => new { Name = s.SecondCourse!, LastUsed = s.UpdatedAt ?? s.CreatedAt }) + .ToListAsync(cancellationToken); + + // Merge in memory. A name appearing on both columns counts once with + // its most recent activity. + var suggestions = firsts + .Concat(seconds) + .GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .Select(g => new { Name = g.Key, LastUsed = g.Max(x => x.LastUsed) }) + .OrderByDescending(g => g.LastUsed) + .Take(limit) + .Select(g => g.Name) + .ToList(); + + return Result>.Success(suggestions); + } + + private static string EscapeLikePattern(string input) + { + // Postgres LIKE wildcards are %, _ and \. Escape so a user typing + // "100%" does not match every record. + return input + .Replace(@"\", @"\\") + .Replace("%", @"\%") + .Replace("_", @"\_"); + } + } +} diff --git a/src/FamilyNido.Api/Features/Meals/GetWeekPlan.cs b/src/FamilyNido.Api/Features/Meals/GetWeekPlan.cs new file mode 100644 index 0000000..6edad52 --- /dev/null +++ b/src/FamilyNido.Api/Features/Meals/GetWeekPlan.cs @@ -0,0 +1,66 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Meals; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Meals; + +/// Slice: fetch the meal plan for the week that contains the supplied start date. +public static class GetWeekPlan +{ + /// Query โ€” any date works; the handler snaps to the Monday of that week. + /// Any date inside the desired week. + public sealed record Query(DateOnly StartDate) : IRequest>; + + /// Handler โ€” composes the 7-day grid from the persisted rows. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> HandleAsync(Query request, CancellationToken cancellationToken) + { + var current = await _userContext.GetAsync(cancellationToken); + if (current is null) + { + return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller."); + } + + var monday = WeekStart.SnapToMonday(request.StartDate); + var sunday = monday.AddDays(6); + + var rows = await _db.MealPlanSlots + .AsNoTracking() + .Where(s => s.FamilyId == current.Family.Id && s.Date >= monday && s.Date <= sunday) + .ToListAsync(cancellationToken); + + // Index by (date, slot) for O(1) lookup when assembling the grid. + var byKey = rows.ToDictionary(r => (r.Date, r.Slot)); + + var days = new List(7); + for (var i = 0; i < 7; i++) + { + var date = monday.AddDays(i); + byKey.TryGetValue((date, MealSlot.Lunch), out var lunch); + byKey.TryGetValue((date, MealSlot.Dinner), out var dinner); + days.Add(new MealDayDto( + date, + lunch is null ? null : MealPlanSlotDto.From(lunch), + dinner is null ? null : MealPlanSlotDto.From(dinner))); + } + + return new MealWeekDto(monday, days); + } + } +} diff --git a/src/FamilyNido.Api/Features/Meals/MealEndpoints.cs b/src/FamilyNido.Api/Features/Meals/MealEndpoints.cs new file mode 100644 index 0000000..3952d8b --- /dev/null +++ b/src/FamilyNido.Api/Features/Meals/MealEndpoints.cs @@ -0,0 +1,98 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Domain.Meals; +using FluentValidation; + +namespace FamilyNido.Api.Features.Meals; + +/// REST endpoints for the meal planner (RF-MEAL-*). +public static class MealEndpoints +{ + /// Registers /api/meals endpoints on the given route group. + public static IEndpointRouteBuilder MapMealEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/meals") + .WithTags("Meals") + .RequireAuthorization(Policies.AuthenticatedUser); + + group.MapGet("/week", GetWeekAsync); + group.MapPut("/slots", UpsertSlotAsync); + group.MapDelete("/slots", ClearSlotAsync); + group.MapGet("/suggestions", GetSuggestionsAsync); + group.MapPost("/week/duplicate-previous", DuplicatePreviousAsync); + + return app; + } + + private static async Task GetWeekAsync( + DateOnly? startDate, + IMediator mediator, + CancellationToken ct) + { + var date = startDate ?? DateOnly.FromDateTime(DateTime.UtcNow); + var result = await mediator.SendAsync(new GetWeekPlan.Query(date), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task UpsertSlotAsync( + UpsertMealSlotBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new UpsertMealSlot.Command(body.Date, body.Slot, body.Course, body.Name); + 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 ClearSlotAsync( + DateOnly date, + MealSlot slot, + MealCourse course, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new ClearMealSlot.Command(date, slot, course), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } + + private static async Task GetSuggestionsAsync( + string? prefix, + int? limit, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new GetMealSuggestions.Query(prefix, limit), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task DuplicatePreviousAsync( + DuplicatePreviousWeekBody body, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync( + new DuplicatePreviousWeek.Command(body.WeekStart, body.Overwrite), + ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } +} + +/// Wire body for PUT /api/meals/slots. +/// Target date. +/// Slot of the day. +/// Course within the slot (First/Second). +/// New name (1..120 chars). +public sealed record UpsertMealSlotBody(DateOnly Date, MealSlot Slot, MealCourse Course, string Name); + +/// Wire body for POST /api/meals/week/duplicate-previous. +/// Any date inside the target week (snapped to Monday). +/// When true, replaces non-empty slots in the destination week. +public sealed record DuplicatePreviousWeekBody(DateOnly WeekStart, bool Overwrite); diff --git a/src/FamilyNido.Api/Features/Meals/MealPlanSlotDto.cs b/src/FamilyNido.Api/Features/Meals/MealPlanSlotDto.cs new file mode 100644 index 0000000..6a5a8f3 --- /dev/null +++ b/src/FamilyNido.Api/Features/Meals/MealPlanSlotDto.cs @@ -0,0 +1,32 @@ +using FamilyNido.Domain.Meals; + +namespace FamilyNido.Api.Features.Meals; + +/// Wire-level view of a single meal-plan slot row. +/// Slot row id (handy for client-side tracking). +/// Date of the slot (YYYY-MM-DD). +/// Which meal of the day (). +/// Primer plato; null when not registered. +/// Segundo plato; null when not registered. +public sealed record MealPlanSlotDto( + Guid Id, + DateOnly Date, + MealSlot Slot, + string? FirstCourse, + string? SecondCourse) +{ + /// Project a domain row to its DTO shape. + public static MealPlanSlotDto From(MealPlanSlot slot) + => new(slot.Id, slot.Date, slot.Slot, slot.FirstCourse, slot.SecondCourse); +} + +/// One day in the weekly plan grid: slots are nullable to signal "empty". +/// Date of the day. +/// Lunch slot for this day, or null if no row exists. +/// Dinner slot for this day, or null if no row exists. +public sealed record MealDayDto(DateOnly Date, MealPlanSlotDto? Lunch, MealPlanSlotDto? Dinner); + +/// Full week response: 7 days from (a Monday). +/// ISO Monday of the week the response covers. +/// Seven days, in order from Monday to Sunday. +public sealed record MealWeekDto(DateOnly WeekStart, IReadOnlyList Days); diff --git a/src/FamilyNido.Api/Features/Meals/UpsertMealSlot.cs b/src/FamilyNido.Api/Features/Meals/UpsertMealSlot.cs new file mode 100644 index 0000000..fa4b320 --- /dev/null +++ b/src/FamilyNido.Api/Features/Meals/UpsertMealSlot.cs @@ -0,0 +1,102 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Meals; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Meals; + +/// +/// Slice: insert or update a single course within a meal slot. Idempotent โ€” +/// rewriting the same course with the same name leaves the row otherwise +/// unchanged. The other course (the one not addressed by this command) is +/// preserved. +/// +public static class UpsertMealSlot +{ + /// Command โ€” addresses a single course within a slot. + /// Date of the slot. + /// Slot of the day (Lunch/Dinner). + /// Which course to write (First/Second). + /// Free-text name (1..120 chars). + public sealed record Command( + DateOnly Date, + MealSlot Slot, + MealCourse Course, + string Name) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator. + public Validator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(120); + } + } + + /// Handler โ€” locates the existing row by unique key or inserts a new one. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> HandleAsync(Command request, CancellationToken cancellationToken) + { + var current = await _userContext.GetAsync(cancellationToken); + if (current is null) + { + return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller."); + } + + var trimmed = request.Name.Trim(); + + var existing = await _db.MealPlanSlots + .FirstOrDefaultAsync( + s => s.FamilyId == current.Family.Id && + s.Date == request.Date && + s.Slot == request.Slot, + cancellationToken); + + if (existing is null) + { + existing = new MealPlanSlot + { + FamilyId = current.Family.Id, + Date = request.Date, + Slot = request.Slot, + FirstCourse = request.Course == MealCourse.First ? trimmed : null, + SecondCourse = request.Course == MealCourse.Second ? trimmed : null, + }; + _db.MealPlanSlots.Add(existing); + } + else + { + if (request.Course == MealCourse.First) + { + existing.FirstCourse = trimmed; + } + else + { + existing.SecondCourse = trimmed; + } + } + + await _db.SaveChangesAsync(cancellationToken); + return MealPlanSlotDto.From(existing); + } + } +} diff --git a/src/FamilyNido.Api/Features/Meals/WeekStart.cs b/src/FamilyNido.Api/Features/Meals/WeekStart.cs new file mode 100644 index 0000000..242984a --- /dev/null +++ b/src/FamilyNido.Api/Features/Meals/WeekStart.cs @@ -0,0 +1,18 @@ +namespace FamilyNido.Api.Features.Meals; + +/// +/// Helpers around the "week starts on Monday" convention used by the meal +/// planner. Centralized so the snap logic is identical in every slice. +/// +public static class WeekStart +{ + /// + /// Snaps any date to the Monday of its ISO week (or returns the same + /// date if it already is a Monday). + /// + public static DateOnly SnapToMonday(DateOnly date) + { + var diff = ((int)date.DayOfWeek + 6) % 7; // Mon=0, Sun=6 + return date.AddDays(-diff); + } +} diff --git a/src/FamilyNido.Api/Features/MemberAgenda/CreateMemberAgendaPattern.cs b/src/FamilyNido.Api/Features/MemberAgenda/CreateMemberAgendaPattern.cs new file mode 100644 index 0000000..0a96988 --- /dev/null +++ b/src/FamilyNido.Api/Features/MemberAgenda/CreateMemberAgendaPattern.cs @@ -0,0 +1,100 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Agenda; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.MemberAgenda; + +/// +/// Slice for POST /api/member-agenda/patterns. Creates a recurring +/// weekly entry for a member. Admin can target any member; non-admins can +/// only target themselves. +/// +public static class CreateMemberAgendaPattern +{ + /// Command carrying the new pattern fields. + public sealed record Command( + Guid MemberId, + DayOfWeek DayOfWeek, + string Label, + string? Location, + TimeOnly? StartTime, + TimeOnly? EndTime, + AgendaTransportMode TransportMode, + bool IsAway, + string? Notes, + bool IsActive) : IRequest>; + + /// Validation: label required, time order sane. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Label).NotEmpty().MaximumLength(120); + RuleFor(x => x.Location).MaximumLength(200); + RuleFor(x => x.Notes).MaximumLength(500); + RuleFor(x => x).Must(c => !(c.StartTime is { } s && c.EndTime is { } e) || e >= s) + .WithMessage("End time must be on or after start time."); + } + } + + /// Persists the row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> HandleAsync(Command request, CancellationToken cancellationToken) + { + var current = await _userContext.GetAsync(cancellationToken); + if (!MemberAgendaPermissions.CanWrite(current, request.MemberId)) + { + return ApplicationError.Forbidden("agenda.forbidden", "Only an admin or the member themselves can edit this agenda."); + } + + var memberFamilyId = await _db.FamilyMembers + .Where(m => m.Id == request.MemberId && m.FamilyId == current!.Family.Id) + .Select(m => (Guid?)m.FamilyId) + .FirstOrDefaultAsync(cancellationToken); + if (memberFamilyId is null) + { + return ApplicationError.Validation("agenda.unknown_member", "Member does not belong to this family."); + } + + var entity = new MemberAgendaPattern + { + FamilyId = memberFamilyId.Value, + FamilyMemberId = request.MemberId, + DayOfWeek = request.DayOfWeek, + Label = request.Label.Trim(), + Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(), + StartTime = request.StartTime, + EndTime = request.EndTime, + TransportMode = request.TransportMode, + IsAway = request.IsAway, + Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(), + IsActive = request.IsActive, + }; + + _db.MemberAgendaPatterns.Add(entity); + await _db.SaveChangesAsync(cancellationToken); + + return new MemberAgendaPatternDto( + entity.Id, entity.FamilyMemberId, entity.DayOfWeek, entity.Label, entity.Location, + entity.StartTime, entity.EndTime, entity.TransportMode, entity.IsAway, entity.Notes, entity.IsActive); + } + } +} diff --git a/src/FamilyNido.Api/Features/MemberAgenda/DeleteMemberAgendaException.cs b/src/FamilyNido.Api/Features/MemberAgenda/DeleteMemberAgendaException.cs new file mode 100644 index 0000000..3afd119 --- /dev/null +++ b/src/FamilyNido.Api/Features/MemberAgenda/DeleteMemberAgendaException.cs @@ -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.MemberAgenda; + +/// Slice for DELETE /api/member-agenda/exceptions/{id}. +public static class DeleteMemberAgendaException +{ + /// Command carrying the exception id. + public sealed record Command(Guid Id) : IRequest>; + + /// Removes the row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 entity = await _db.MemberAgendaExceptions + .FirstOrDefaultAsync(e => e.Id == request.Id && e.FamilyId == current.Family.Id, cancellationToken); + if (entity is null) + { + return Unit.Value; + } + if (!MemberAgendaPermissions.CanWrite(current, entity.FamilyMemberId)) + { + return ApplicationError.Forbidden("agenda.forbidden", "Only an admin or the member themselves can edit this agenda."); + } + + _db.MemberAgendaExceptions.Remove(entity); + await _db.SaveChangesAsync(cancellationToken); + return Unit.Value; + } + } +} diff --git a/src/FamilyNido.Api/Features/MemberAgenda/DeleteMemberAgendaPattern.cs b/src/FamilyNido.Api/Features/MemberAgenda/DeleteMemberAgendaPattern.cs new file mode 100644 index 0000000..c6ecbbe --- /dev/null +++ b/src/FamilyNido.Api/Features/MemberAgenda/DeleteMemberAgendaPattern.cs @@ -0,0 +1,58 @@ +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.MemberAgenda; + +/// +/// Slice for DELETE /api/member-agenda/patterns/{id}. Cascade drops +/// any per-date overrides of this pattern (ad-hoc rows are unaffected). +/// +public static class DeleteMemberAgendaPattern +{ + /// Command carrying the pattern id. + public sealed record Command(Guid Id) : IRequest>; + + /// Removes the row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 entity = await _db.MemberAgendaPatterns + .FirstOrDefaultAsync(p => p.Id == request.Id && p.FamilyId == current.Family.Id, cancellationToken); + if (entity is null) + { + return ApplicationError.NotFound("agenda.pattern_not_found", "Agenda pattern not found."); + } + if (!MemberAgendaPermissions.CanWrite(current, entity.FamilyMemberId)) + { + return ApplicationError.Forbidden("agenda.forbidden", "Only an admin or the member themselves can edit this agenda."); + } + + _db.MemberAgendaPatterns.Remove(entity); + await _db.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } + } +} diff --git a/src/FamilyNido.Api/Features/MemberAgenda/GetMemberAgendaOverview.cs b/src/FamilyNido.Api/Features/MemberAgenda/GetMemberAgendaOverview.cs new file mode 100644 index 0000000..43f0a80 --- /dev/null +++ b/src/FamilyNido.Api/Features/MemberAgenda/GetMemberAgendaOverview.cs @@ -0,0 +1,150 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Agenda; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.MemberAgenda; + +/// +/// Slice for GET /api/member-agenda/overview?from=&to=. Returns every +/// pattern + exception in the family plus a day-by-day "resolved" view that +/// merges them. The frontend shows the resolved list as-is (panel, member +/// detail page) and the raw lists when editing. +/// +public static class GetMemberAgendaOverview +{ + /// Inclusive [From, To] range query. + public sealed record Query(DateOnly From, DateOnly To) : IRequest>; + + /// Composes the overview DTO. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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."); + } + if (request.To < request.From) + { + return ApplicationError.Validation("agenda.overview.bad_range", "End date must be on or after the start date."); + } + + var familyId = current.Family.Id; + + var patterns = await _db.MemberAgendaPatterns + .AsNoTracking() + .Where(p => p.FamilyId == familyId) + .OrderBy(p => p.FamilyMemberId).ThenBy(p => p.DayOfWeek).ThenBy(p => p.StartTime) + .ToListAsync(cancellationToken); + + var exceptions = await _db.MemberAgendaExceptions + .AsNoTracking() + .Where(e => e.FamilyId == familyId && e.Date >= request.From && e.Date <= request.To) + .OrderBy(e => e.Date) + .ToListAsync(cancellationToken); + + var patternDtos = patterns + .Select(p => new MemberAgendaPatternDto( + p.Id, p.FamilyMemberId, p.DayOfWeek, p.Label, p.Location, + p.StartTime, p.EndTime, p.TransportMode, p.IsAway, p.Notes, p.IsActive)) + .ToList(); + + var exceptionDtos = exceptions + .Select(e => new MemberAgendaExceptionDto( + e.Id, e.FamilyMemberId, e.Date, e.PatternId, e.IsCancelled, e.Label, e.Location, + e.StartTime, e.EndTime, e.TransportMode, e.IsAway, e.Notes)) + .ToList(); + + var resolved = Resolve(patterns, exceptions, request.From, request.To); + + return new MemberAgendaOverviewDto(request.From, request.To, patternDtos, exceptionDtos, resolved); + } + + /// + /// Build day-by-day resolved entries. For each date in range: + /// 1) Take active patterns whose weekday matches. + /// 2) Drop patterns that have a cancelling exception on that date. + /// 3) Apply non-cancel exceptions as overrides on top of their pattern. + /// 4) Append ad-hoc exceptions (PatternId null) as standalone entries. + /// + internal static IReadOnlyList Resolve( + IReadOnlyList patterns, + IReadOnlyList exceptions, + DateOnly from, + DateOnly to) + { + var byPattern = exceptions + .Where(e => e.PatternId is not null) + .ToDictionary(e => (e.PatternId!.Value, e.Date)); + + var result = new List(); + + for (var date = from; date <= to; date = date.AddDays(1)) + { + foreach (var pattern in patterns) + { + if (!pattern.IsActive) continue; + if (pattern.DayOfWeek != date.DayOfWeek) continue; + + if (byPattern.TryGetValue((pattern.Id, date), out var ex)) + { + if (ex.IsCancelled) continue; + result.Add(new ResolvedAgendaEntryDto( + pattern.FamilyMemberId, date, pattern.Id, ex.Id, + ex.Label ?? pattern.Label, + ex.Location ?? pattern.Location, + ex.StartTime ?? pattern.StartTime, + ex.EndTime ?? pattern.EndTime, + ex.TransportMode ?? pattern.TransportMode, + ex.IsAway ?? pattern.IsAway, + ex.Notes ?? pattern.Notes)); + continue; + } + + result.Add(new ResolvedAgendaEntryDto( + pattern.FamilyMemberId, date, pattern.Id, ExceptionId: null, + pattern.Label, pattern.Location, pattern.StartTime, pattern.EndTime, + pattern.TransportMode, pattern.IsAway, pattern.Notes)); + } + } + + // Ad-hoc exceptions: PatternId is null. They must fall in range + // (already filtered by the SQL query) and contribute regardless of + // weekday. Cancelled ad-hocs are nonsense and ignored. + foreach (var ex in exceptions.Where(e => e.PatternId is null && !e.IsCancelled)) + { + result.Add(new ResolvedAgendaEntryDto( + ex.FamilyMemberId, ex.Date, PatternId: null, ex.Id, + ex.Label ?? "(sin etiqueta)", + ex.Location, + ex.StartTime, + ex.EndTime, + ex.TransportMode ?? AgendaTransportMode.None, + ex.IsAway ?? true, + ex.Notes)); + } + + return result + .OrderBy(r => r.Date) + .ThenBy(r => r.MemberId) + .ThenBy(r => r.StartTime ?? TimeOnly.MinValue) + .ToList(); + } + } +} diff --git a/src/FamilyNido.Api/Features/MemberAgenda/MemberAgendaDtos.cs b/src/FamilyNido.Api/Features/MemberAgenda/MemberAgendaDtos.cs new file mode 100644 index 0000000..f0e6da0 --- /dev/null +++ b/src/FamilyNido.Api/Features/MemberAgenda/MemberAgendaDtos.cs @@ -0,0 +1,54 @@ +using FamilyNido.Domain.Agenda; + +namespace FamilyNido.Api.Features.MemberAgenda; + +/// Wire shape of a row. +public sealed record MemberAgendaPatternDto( + Guid Id, + Guid MemberId, + DayOfWeek DayOfWeek, + string Label, + string? Location, + TimeOnly? StartTime, + TimeOnly? EndTime, + AgendaTransportMode TransportMode, + bool IsAway, + string? Notes, + bool IsActive); + +/// Wire shape of a row. +public sealed record MemberAgendaExceptionDto( + Guid Id, + Guid MemberId, + DateOnly Date, + Guid? PatternId, + bool IsCancelled, + string? Label, + string? Location, + TimeOnly? StartTime, + TimeOnly? EndTime, + AgendaTransportMode? TransportMode, + bool? IsAway, + string? Notes); + +/// One resolved agenda entry for a single (member, date) cell. +public sealed record ResolvedAgendaEntryDto( + Guid MemberId, + DateOnly Date, + Guid? PatternId, + Guid? ExceptionId, + string Label, + string? Location, + TimeOnly? StartTime, + TimeOnly? EndTime, + AgendaTransportMode TransportMode, + bool IsAway, + string? Notes); + +/// Bundle returned by GET /api/member-agenda/overview. +public sealed record MemberAgendaOverviewDto( + DateOnly From, + DateOnly To, + IReadOnlyList Patterns, + IReadOnlyList Exceptions, + IReadOnlyList Resolved); diff --git a/src/FamilyNido.Api/Features/MemberAgenda/MemberAgendaEndpoints.cs b/src/FamilyNido.Api/Features/MemberAgenda/MemberAgendaEndpoints.cs new file mode 100644 index 0000000..fc44dd2 --- /dev/null +++ b/src/FamilyNido.Api/Features/MemberAgenda/MemberAgendaEndpoints.cs @@ -0,0 +1,144 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Domain.Agenda; +using FluentValidation; + +namespace FamilyNido.Api.Features.MemberAgenda; + +/// HTTP surface of the member-agenda module. +public static class MemberAgendaEndpoints +{ + /// Registers the /api/member-agenda/* routes on the given builder. + public static IEndpointRouteBuilder MapMemberAgendaEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/member-agenda").WithTags("MemberAgenda"); + + // Read access for any authenticated user โ€” agenda visibility is family-internal. + group.MapGet("/overview", GetOverviewAsync).RequireAuthorization(Policies.AuthenticatedUser); + + // Mutations are gated to authenticated user, with admin-or-self enforced inside the slice. + group.MapPost("/patterns", CreatePatternAsync).RequireAuthorization(Policies.AuthenticatedUser); + group.MapPut("/patterns/{id:guid}", UpdatePatternAsync).RequireAuthorization(Policies.AuthenticatedUser); + group.MapDelete("/patterns/{id:guid}", DeletePatternAsync).RequireAuthorization(Policies.AuthenticatedUser); + + group.MapPost("/exceptions", CreateExceptionAsync).RequireAuthorization(Policies.AuthenticatedUser); + group.MapPut("/exceptions/{id:guid}", UpdateExceptionAsync).RequireAuthorization(Policies.AuthenticatedUser); + group.MapDelete("/exceptions/{id:guid}", DeleteExceptionAsync).RequireAuthorization(Policies.AuthenticatedUser); + + return app; + } + + private static async Task GetOverviewAsync( + DateOnly from, + DateOnly to, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new GetMemberAgendaOverview.Query(from, to), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task CreatePatternAsync( + PatternBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new CreateMemberAgendaPattern.Command( + body.MemberId, body.DayOfWeek, body.Label, body.Location, + body.StartTime, body.EndTime, body.TransportMode, body.IsAway, body.Notes, body.IsActive); + 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 UpdatePatternAsync( + Guid id, + PatternBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new UpdateMemberAgendaPattern.Command( + id, body.DayOfWeek, body.Label, body.Location, + body.StartTime, body.EndTime, body.TransportMode, body.IsAway, body.Notes, body.IsActive); + 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 DeletePatternAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new DeleteMemberAgendaPattern.Command(id), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } + + private static async Task CreateExceptionAsync( + ExceptionBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new SetMemberAgendaException.Command( + Id: null, + body.MemberId, body.Date, body.PatternId, body.IsCancelled, body.Label, body.Location, + body.StartTime, body.EndTime, body.TransportMode, body.IsAway, 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 UpdateExceptionAsync( + Guid id, + ExceptionBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new SetMemberAgendaException.Command( + id, + body.MemberId, body.Date, body.PatternId, body.IsCancelled, body.Label, body.Location, + body.StartTime, body.EndTime, body.TransportMode, body.IsAway, 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 DeleteExceptionAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new DeleteMemberAgendaException.Command(id), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } + + /// Body for create/update pattern. + public sealed record PatternBody( + Guid MemberId, + DayOfWeek DayOfWeek, + string Label, + string? Location, + TimeOnly? StartTime, + TimeOnly? EndTime, + AgendaTransportMode TransportMode, + bool IsAway, + string? Notes, + bool IsActive); + + /// Body for create/update exception. + public sealed record ExceptionBody( + Guid MemberId, + DateOnly Date, + Guid? PatternId, + bool IsCancelled, + string? Label, + string? Location, + TimeOnly? StartTime, + TimeOnly? EndTime, + AgendaTransportMode? TransportMode, + bool? IsAway, + string? Notes); +} diff --git a/src/FamilyNido.Api/Features/MemberAgenda/MemberAgendaPermissions.cs b/src/FamilyNido.Api/Features/MemberAgenda/MemberAgendaPermissions.cs new file mode 100644 index 0000000..65bb8c2 --- /dev/null +++ b/src/FamilyNido.Api/Features/MemberAgenda/MemberAgendaPermissions.cs @@ -0,0 +1,20 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Api.Features.MemberAgenda; + +/// +/// Centralised admin-or-self check used by every write slice in the agenda +/// module. Admin can edit anyone's agenda; an authenticated user can edit +/// their own (the member they are linked to). +/// +internal static class MemberAgendaPermissions +{ + /// True when can mutate 's agenda. + public static bool CanWrite(CurrentUser? current, Guid targetMemberId) + { + if (current is null) return false; + if (current.User.Role == FamilyRole.Admin) return true; + return current.Member.Id == targetMemberId; + } +} diff --git a/src/FamilyNido.Api/Features/MemberAgenda/SetMemberAgendaException.cs b/src/FamilyNido.Api/Features/MemberAgenda/SetMemberAgendaException.cs new file mode 100644 index 0000000..c5be214 --- /dev/null +++ b/src/FamilyNido.Api/Features/MemberAgenda/SetMemberAgendaException.cs @@ -0,0 +1,139 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Agenda; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.MemberAgenda; + +/// +/// Slice for POST /api/member-agenda/exceptions + PUT +/// /api/member-agenda/exceptions/{id}. Upserts a per-date exception: +/// either an override of an existing pattern (PatternId set) or an ad-hoc +/// one-off entry (PatternId null). +/// +public static class SetMemberAgendaException +{ + /// + /// Command. null = create; non-null = update that + /// row in place. For pattern overrides, + + /// + uniquely + /// identify the row, but having an explicit id keeps the URL clean. + /// + public sealed record Command( + Guid? Id, + Guid MemberId, + DateOnly Date, + Guid? PatternId, + bool IsCancelled, + string? Label, + string? Location, + TimeOnly? StartTime, + TimeOnly? EndTime, + AgendaTransportMode? TransportMode, + bool? IsAway, + string? Notes) : IRequest>; + + /// Validation: ad-hoc entries can't be cancellations and must have a label; time order sane. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Label).MaximumLength(120); + RuleFor(x => x.Location).MaximumLength(200); + RuleFor(x => x.Notes).MaximumLength(500); + RuleFor(x => x).Must(c => !(c.PatternId is null && c.IsCancelled)) + .WithMessage("Ad-hoc entries cannot be cancellations."); + RuleFor(x => x).Must(c => c.PatternId is not null || !string.IsNullOrWhiteSpace(c.Label)) + .WithMessage("Ad-hoc entries must carry a label."); + RuleFor(x => x).Must(c => !(c.StartTime is { } s && c.EndTime is { } e) || e >= s) + .WithMessage("End time must be on or after start time."); + } + } + + /// Persists the upsert. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> HandleAsync(Command request, CancellationToken cancellationToken) + { + var current = await _userContext.GetAsync(cancellationToken); + if (!MemberAgendaPermissions.CanWrite(current, request.MemberId)) + { + return ApplicationError.Forbidden("agenda.forbidden", "Only an admin or the member themselves can edit this agenda."); + } + + var familyId = current!.Family.Id; + + var memberOk = await _db.FamilyMembers + .AnyAsync(m => m.Id == request.MemberId && m.FamilyId == familyId, cancellationToken); + if (!memberOk) + { + return ApplicationError.Validation("agenda.unknown_member", "Member does not belong to this family."); + } + + // When overriding a pattern, ensure the pattern exists and belongs to the same member. + if (request.PatternId is { } pid) + { + var patternBelongs = await _db.MemberAgendaPatterns + .AnyAsync(p => p.Id == pid && p.FamilyMemberId == request.MemberId && p.FamilyId == familyId, cancellationToken); + if (!patternBelongs) + { + return ApplicationError.Validation("agenda.unknown_pattern", "Pattern does not belong to this member."); + } + } + + MemberAgendaException? entity; + if (request.Id is { } id) + { + entity = await _db.MemberAgendaExceptions + .FirstOrDefaultAsync(e => e.Id == id && e.FamilyId == familyId, cancellationToken); + if (entity is null) + { + return ApplicationError.NotFound("agenda.exception_not_found", "Exception not found."); + } + } + else + { + entity = new MemberAgendaException + { + FamilyId = familyId, + FamilyMemberId = request.MemberId, + Date = request.Date, + PatternId = request.PatternId, + }; + _db.MemberAgendaExceptions.Add(entity); + } + + entity.IsCancelled = request.IsCancelled; + entity.Label = string.IsNullOrWhiteSpace(request.Label) ? null : request.Label.Trim(); + entity.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(); + entity.StartTime = request.StartTime; + entity.EndTime = request.EndTime; + entity.TransportMode = request.TransportMode; + entity.IsAway = request.IsAway; + entity.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + + await _db.SaveChangesAsync(cancellationToken); + + return new MemberAgendaExceptionDto( + entity.Id, entity.FamilyMemberId, entity.Date, entity.PatternId, entity.IsCancelled, + entity.Label, entity.Location, entity.StartTime, entity.EndTime, + entity.TransportMode, entity.IsAway, entity.Notes); + } + } +} diff --git a/src/FamilyNido.Api/Features/MemberAgenda/UpdateMemberAgendaPattern.cs b/src/FamilyNido.Api/Features/MemberAgenda/UpdateMemberAgendaPattern.cs new file mode 100644 index 0000000..70f4f71 --- /dev/null +++ b/src/FamilyNido.Api/Features/MemberAgenda/UpdateMemberAgendaPattern.cs @@ -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.Agenda; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.MemberAgenda; + +/// +/// Slice for PUT /api/member-agenda/patterns/{id}. Replaces the row in +/// place. Admin can edit any pattern; non-admins only their own. +/// +public static class UpdateMemberAgendaPattern +{ + /// Command carrying the new field values. + public sealed record Command( + Guid Id, + DayOfWeek DayOfWeek, + string Label, + string? Location, + TimeOnly? StartTime, + TimeOnly? EndTime, + AgendaTransportMode TransportMode, + bool IsAway, + string? Notes, + bool IsActive) : IRequest>; + + /// Validation: label required, time order sane. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Label).NotEmpty().MaximumLength(120); + RuleFor(x => x.Location).MaximumLength(200); + RuleFor(x => x.Notes).MaximumLength(500); + RuleFor(x => x).Must(c => !(c.StartTime is { } s && c.EndTime is { } e) || e >= s) + .WithMessage("End time must be on or after start time."); + } + } + + /// Mutates the row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 entity = await _db.MemberAgendaPatterns + .FirstOrDefaultAsync(p => p.Id == request.Id && p.FamilyId == current.Family.Id, cancellationToken); + if (entity is null) + { + return ApplicationError.NotFound("agenda.pattern_not_found", "Agenda pattern not found."); + } + if (!MemberAgendaPermissions.CanWrite(current, entity.FamilyMemberId)) + { + return ApplicationError.Forbidden("agenda.forbidden", "Only an admin or the member themselves can edit this agenda."); + } + + entity.DayOfWeek = request.DayOfWeek; + entity.Label = request.Label.Trim(); + entity.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(); + entity.StartTime = request.StartTime; + entity.EndTime = request.EndTime; + entity.TransportMode = request.TransportMode; + entity.IsAway = request.IsAway; + entity.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + entity.IsActive = request.IsActive; + + await _db.SaveChangesAsync(cancellationToken); + + return new MemberAgendaPatternDto( + entity.Id, entity.FamilyMemberId, entity.DayOfWeek, entity.Label, entity.Location, + entity.StartTime, entity.EndTime, entity.TransportMode, entity.IsAway, entity.Notes, entity.IsActive); + } + } +} diff --git a/src/FamilyNido.Api/Features/Notifications/BackendLocalization.cs b/src/FamilyNido.Api/Features/Notifications/BackendLocalization.cs new file mode 100644 index 0000000..b7a9ddb --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/BackendLocalization.cs @@ -0,0 +1,145 @@ +using System.Globalization; + +namespace FamilyNido.Api.Features.Notifications; + +/// +/// Tiny lookup-based localizer for the handful of server-side strings that +/// reach end users (email subjects/body chunks, integration-generated task +/// titles). The frontend has its own $localize pipeline; this is the +/// deliberately simpler equivalent for the backend. +/// +/// +/// +/// Not a full IStringLocalizer<T>: there's no .resx loader, no +/// fallback chain, no missing-key warnings. Each entry is a switch arm โ€” if +/// you add a new locale you add a new case. Keys are namespaced by +/// origin (email.digest.greeting, weather.code.rain) so the +/// file reads top-down by feature. +/// +/// +/// Locale tags follow BCP-47 with a coarse fallback: any tag whose primary +/// subtag is en resolves to the English entries; everything else +/// (including the default es-ES) resolves to Spanish. +/// +/// +public static class BackendLocalization +{ + /// Lookup the localized string for a key. Returns the Spanish source if the key is unknown. + /// Stable key (e.g. email.digest.section.tasks). + /// BCP-47 tag from . + public static string T(string key, string lang) + { + if (IsEnglish(lang)) + { + return EnglishOrSpanish(key, english: true); + } + return EnglishOrSpanish(key, english: false); + } + + /// Format a date in the locale-appropriate long form ("d 'de' MMMM" in Spanish, "MMMM d" in English). + public static string FormatLongDate(DateTime date, string lang) => IsEnglish(lang) + ? date.ToString("MMMM d", CultureInfo.GetCultureInfo("en-US")) + : date.ToString("d 'de' MMMM", CultureInfo.GetCultureInfo("es-ES")); + + /// + /// Build the locale-aware path prefix used in email links: /es or /en. + /// Mirrors the Angular i18n.locales subPath config in angular.json. + /// + public static string PathPrefix(string lang) => IsEnglish(lang) ? "/en" : "/es"; + + /// Coarse "is the user asking for English?" check based on the BCP-47 primary subtag. + private static bool IsEnglish(string lang) + { + if (string.IsNullOrEmpty(lang)) return false; + var dash = lang.IndexOf('-'); + var primary = dash < 0 ? lang : lang[..dash]; + return string.Equals(primary, "en", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Centralised dictionary. Kept as one switch so a translator can scan + /// the file linearly. Add new keys here and they become available + /// immediately through . + /// + private static string EnglishOrSpanish(string key, bool english) => key switch + { + // โ”€โ”€ Digest email โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "email.digest.subject" => + english ? "Your day in FamilyNido" : "Tu dรญa en FamilyNido", + "email.digest.greeting" => + english ? "Good morning" : "Buenos dรญas", + "email.digest.intro" => + english ? "this is your day in a glance." : "este es tu resumen del dรญa.", + "email.digest.section.tasks" => + english ? "๐Ÿชบ Today's tasks" : "๐Ÿชบ Tareas para hoy", + "email.digest.section.events" => + english ? "๐Ÿ“… Your agenda" : "๐Ÿ“… Tu agenda", + "email.digest.section.agenda" => + english ? "๐Ÿš— Out of the house today" : "๐Ÿš— Hoy fuera de casa", + "email.digest.section.school" => + english ? "๐ŸŽ’ School today" : "๐ŸŽ’ Hoy en el cole", + "email.digest.section.meals" => + english ? "๐Ÿฝ๏ธ At the table" : "๐Ÿฝ๏ธ Hoy se come", + "email.digest.section.birthdays" => + english ? "๐ŸŽ‚ Birthdays" : "๐ŸŽ‚ Cumpleaรฑos", + "email.digest.section.wall" => + english ? "๐Ÿ’ฌ New on the wall" : "๐Ÿ’ฌ Nuevo en el muro", + "email.digest.cta" => + english ? "Open FamilyNido" : "Abrir FamilyNido", + + // โ”€โ”€ Task assigned email โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "email.task-assigned.subject" => + english ? "New task for you: " : "Nueva tarea para ti: ", + "email.task-assigned.greeting" => + english ? "Hi" : "Hola", + "email.task-assigned.body" => + english ? "has assigned you a task as the responsible:" : "te ha asignado una tarea como responsable:", + "email.task-assigned.due-prefix" => + english ? "Due " : "Para el ", + "email.task-assigned.cta" => + english ? "View in FamilyNido" : "Ver en FamilyNido", + + // โ”€โ”€ Wall mention email โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "email.wall-mention.subject-suffix" => + english ? " mentioned you on the wall" : " te ha mencionado en el muro", + "email.wall-mention.greeting" => + english ? "Hi" : "Hola", + "email.wall-mention.body-prefix" => + english ? "mentioned you in" : "te ha mencionado en", + "email.wall-mention.context.message" => + english ? "a wall message" : "un mensaje del muro", + "email.wall-mention.context.comment" => + english ? "a wall comment" : "un comentario del muro", + "email.wall-mention.cta" => + english ? "Go to the wall" : "Ir al muro", + + // โ”€โ”€ Shared shell โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "email.footer" => + english + ? "You're receiving this email because you have this notification turned on. You can change that in My account." + : "Recibes este email porque tienes activadas las notificaciones de este tipo. Puedes ajustarlas en Mi cuenta.", + + // โ”€โ”€ Weather labels (WMO codes โ†’ short label) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "weather.code.0" => english ? "Clear" : "Despejado", + "weather.code.1" => english ? "Mostly clear" : "Mayormente despejado", + "weather.code.2" => english ? "Partly cloudy" : "Parcialmente nublado", + "weather.code.3" => english ? "Cloudy" : "Nublado", + "weather.code.fog" => english ? "Fog" : "Niebla", + "weather.code.drizzle" => english ? "Drizzle" : "Llovizna", + "weather.code.freezing-drizzle" => english ? "Freezing drizzle" : "Llovizna helada", + "weather.code.rain" => english ? "Rain" : "Lluvia", + "weather.code.freezing-rain" => english ? "Freezing rain" : "Lluvia helada", + "weather.code.snow" => english ? "Snow" : "Nieve", + "weather.code.sleet" => english ? "Sleet" : "Aguanieve", + "weather.code.showers" => english ? "Showers" : "Chubascos", + "weather.code.snow-showers" => english ? "Snow showers" : "Nevadas", + "weather.code.thunderstorm" => english ? "Thunderstorm" : "Tormenta", + "weather.code.thunderstorm-hail" => english ? "Thunderstorm with hail" : "Tormenta con granizo", + "weather.code.unknown" => english ? "No data" : "Sin datos", + + // Unknown key: surface the key itself in dev so the missing string is + // visible in the email instead of silently empty. Source-locale fallback + // would be friendlier but also masks bugs. + _ => $"[{key}]", + }; +} diff --git a/src/FamilyNido.Api/Features/Notifications/EmailDigestBackgroundService.cs b/src/FamilyNido.Api/Features/Notifications/EmailDigestBackgroundService.cs new file mode 100644 index 0000000..522265e --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/EmailDigestBackgroundService.cs @@ -0,0 +1,646 @@ +using System.Globalization; +using FamilyNido.Api.Features.MemberAgenda; +using FamilyNido.Api.Options; +using FamilyNido.Domain.HouseholdTasks; +using FamilyNido.Domain.Meals; +using FamilyNido.Domain.Notifications; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace FamilyNido.Api.Features.Notifications; + +/// +/// Hosted service that wakes every few minutes, walks through every family in +/// the database, and queues a "today in FamilyNido" digest for each member that +/// hasn't received one yet on their family's local date. +/// +/// +/// +/// Idempotency is delegated to the table: the +/// composite primary key (UserId, LocalDate) doubles as a lock, so the +/// service inserts the row first and only queues the email when the insert +/// succeeded โ€” a duplicate from a concurrent tick fails the insert and the +/// branch is skipped. +/// +/// +/// The morning hour is read from and is +/// applied per family in the family's IANA timezone. Members with the master +/// switch off, the digest channel off, or no linked email are silently +/// skipped โ€” but the run row is still inserted so we don't keep checking them +/// every five minutes for the rest of the day. +/// +/// +public sealed class EmailDigestBackgroundService : BackgroundService +{ + private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(5); + private static readonly TimeSpan InitialDelay = TimeSpan.FromSeconds(45); + private static readonly CultureInfo SpanishCulture = CultureInfo.GetCultureInfo("es-ES"); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly IOptionsMonitor _emailOptions; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + /// Primary constructor. + public EmailDigestBackgroundService( + IServiceScopeFactory scopeFactory, + IOptionsMonitor emailOptions, + TimeProvider timeProvider, + ILogger logger) + { + _scopeFactory = scopeFactory; + _emailOptions = emailOptions; + _timeProvider = timeProvider; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await Task.Delay(InitialDelay, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + await TickOnceAsync(stoppingToken); + + try + { + await Task.Delay(TickInterval, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + + private async Task TickOnceAsync(CancellationToken stoppingToken) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var sp = scope.ServiceProvider; + var db = sp.GetRequiredService(); + var dispatcher = sp.GetRequiredService(); + + var emailOpts = _emailOptions.CurrentValue; + var nowUtc = _timeProvider.GetUtcNow(); + + var families = await db.Families.AsNoTracking().ToListAsync(stoppingToken); + + foreach (var family in families) + { + if (stoppingToken.IsCancellationRequested) return; + await ProcessFamilyAsync(db, dispatcher, family.Id, family.TimeZone, nowUtc, emailOpts, stoppingToken); + } + } + catch (OperationCanceledException) + { + // Shutdown โ€” the loop returns from Task.Delay above. + } + catch (Exception ex) + { + _logger.LogError(ex, "Email digest tick failed at the top level."); + } + } + + private async Task ProcessFamilyAsync( + ApplicationDbContext db, + EmailDispatchService dispatcher, + Guid familyId, + string ianaTimeZone, + DateTimeOffset nowUtc, + EmailOptions emailOpts, + CancellationToken cancellationToken) + { + TimeZoneInfo tz; + try + { + tz = TimeZoneInfo.FindSystemTimeZoneById(ianaTimeZone); + } + catch (TimeZoneNotFoundException) + { + _logger.LogWarning("Family {FamilyId} has unknown timezone {Tz}; falling back to UTC.", familyId, ianaTimeZone); + tz = TimeZoneInfo.Utc; + } + + var localNow = TimeZoneInfo.ConvertTime(nowUtc, tz); + if (localNow.Hour < emailOpts.DigestHour) + { + return; + } + + var localDate = DateOnly.FromDateTime(localNow.DateTime); + + // Recipients are members of this family that have a linked user โ€” children + // without an account never receive emails. + var recipients = await db.FamilyMembers + .AsNoTracking() + .Where(m => m.FamilyId == familyId && m.IsActive && m.UserId != null && m.User!.Email != string.Empty) + .Select(m => new RecipientRow(m.Id, m.DisplayName, m.UserId!.Value, m.User!.Email, m.User!.PreferredLanguage)) + .ToListAsync(cancellationToken); + + foreach (var recipient in recipients) + { + if (cancellationToken.IsCancellationRequested) return; + + var alreadyProcessed = await db.EmailDigestRuns + .AnyAsync(r => r.UserId == recipient.UserId && r.LocalDate == localDate, cancellationToken); + if (alreadyProcessed) continue; + + // Mark the user as processed first so a concurrent tick โ€” or a crash + // partway through โ€” never double-sends. We swallow unique-constraint + // races silently (another instance already grabbed it). + db.EmailDigestRuns.Add(new EmailDigestRun + { + UserId = recipient.UserId, + LocalDate = localDate, + SentAt = nowUtc, + }); + try + { + await db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // Race with another tick โ€” skip silently and rely on the row that won. + db.ChangeTracker.Clear(); + continue; + } + + var prefs = await db.UserNotificationPreferences + .AsNoTracking() + .FirstOrDefaultAsync(p => p.UserId == recipient.UserId, cancellationToken); + + var emailEnabled = prefs?.EmailEnabled ?? true; + var digestEnabled = prefs?.DigestEnabled ?? true; + if (!emailEnabled || !digestEnabled) continue; + + var content = await BuildContentAsync(db, familyId, recipient, localDate, tz, cancellationToken); + if (content.IsEmpty) continue; + + var (subject, html) = EmailTemplates.Digest(recipient.DisplayName, content, emailOpts.AppBaseUrl, recipient.Language); + dispatcher.Queue(new EmailMessage(recipient.Email, subject, html)); + } + } + + /// + /// Build the per-recipient digest content for a given local date. Exposed + /// internally so (manual trigger) reuses the + /// exact same composition logic as the scheduled tick. + /// + internal static async Task BuildContentAsync( + ApplicationDbContext db, + Guid familyId, + RecipientRow recipient, + DateOnly localDate, + TimeZoneInfo tz, + CancellationToken cancellationToken) + { + var tomorrow = localDate.AddDays(1); + var startUtc = new DateTimeOffset(localDate.ToDateTime(TimeOnly.MinValue), tz.GetUtcOffset(localDate.ToDateTime(TimeOnly.MinValue))).ToUniversalTime(); + var endUtc = new DateTimeOffset(tomorrow.ToDateTime(TimeOnly.MinValue), tz.GetUtcOffset(tomorrow.ToDateTime(TimeOnly.MinValue))).ToUniversalTime(); + + // โ”€โ”€ Tasks: every chore scheduled for today in the family that is still + // pending. We surface the whole family roster (not just "yours") + // because the digest is also useful as a "who is doing what today" + // overview, and family-internal unassigned chores ("Sacar la basura") + // would otherwise be invisible when nobody is set as responsible. The + // role label tells the recipient at a glance whether they need to act. + // Tasks already completed for today are filtered out โ€” the dashboard + // widget still shows them (with a strike-through), but in the email + // they would just be noise. + var rawTasks = await db.HouseholdTasks + .AsNoTracking() + .Include(t => t.ResponsibleMember) + .Include(t => t.RelatedMembers) + .Include(t => t.Completions) + .Where(t => t.FamilyId == familyId && !t.IsArchived) + .ToListAsync(cancellationToken); + + var taskItems = new List(); + foreach (var task in rawTasks) + { + if (!task.HasOccurrenceOn(localDate)) continue; + if (task.Completions.Any(c => c.OccurrenceDate == localDate)) continue; + + string role; + if (task.ResponsibleMemberId == recipient.MemberId) + { + role = "Responsable: tรบ"; + } + else if (task.RelatedMembers.Any(m => m.Id == recipient.MemberId)) + { + role = task.ResponsibleMember is { } rm + ? $"Relacionado ยท responsable {rm.DisplayName}" + : "Relacionado"; + } + else if (task.ResponsibleMember is { } rm2) + { + role = $"Responsable: {rm2.DisplayName}"; + } + else + { + role = "Sin responsable"; + } + + var detail = task.TimeOfDay is { } t + ? $"{role} ยท {t:HH:mm}" + : role; + taskItems.Add(new EmailTemplates.DigestItem(task.Title, detail)); + } + + // โ”€โ”€ Events: intersect today's local window. Surface events that + // belong to the recipient (by linked-calendar or per-event tagging) + // AND events on calendars without an assigned member โ€” those are the + // "shared / family" calendars and previously stayed silent in the + // digest, even though every adult cared about them. + var events = await db.CalendarEvents + .AsNoTracking() + .Include(e => e.LinkedCalendar) + .Include(e => e.RelatedMembers) + .Where(e => e.FamilyId == familyId + && e.StartAt < endUtc + && e.EndAt > startUtc + && (e.LinkedCalendar!.FamilyMemberId == recipient.MemberId + || e.LinkedCalendar.FamilyMemberId == null + || e.RelatedMembers.Any(m => m.Id == recipient.MemberId))) + .OrderBy(e => e.StartAt) + .ToListAsync(cancellationToken); + + var eventItems = events.Select(e => + { + string detail; + if (e.IsAllDay) + { + detail = string.IsNullOrEmpty(e.Location) ? "Todo el dรญa" : $"Todo el dรญa ยท {e.Location}"; + } + else + { + var startLocal = TimeZoneInfo.ConvertTime(e.StartAt, tz); + var endLocal = TimeZoneInfo.ConvertTime(e.EndAt, tz); + var range = $"{startLocal:HH:mm}โ€“{endLocal:HH:mm}"; + detail = string.IsNullOrEmpty(e.Location) ? range : $"{range} ยท {e.Location}"; + } + return new EmailTemplates.DigestItem(e.Title, detail); + }).ToList(); + + // โ”€โ”€ Agenda: members away from home today (work, regular activities, + // ad-hoc travel). Reuses GetMemberAgendaOverview.Resolve so the digest + // and the dashboard widget agree on what "today" looks like. + var agendaPatterns = await db.MemberAgendaPatterns + .AsNoTracking() + .Include(p => p.FamilyMember) + .Where(p => p.FamilyId == familyId) + .ToListAsync(cancellationToken); + + var agendaExceptions = await db.MemberAgendaExceptions + .AsNoTracking() + .Include(e => e.FamilyMember) + .Where(e => e.FamilyId == familyId && e.Date == localDate) + .ToListAsync(cancellationToken); + + var resolvedAgenda = GetMemberAgendaOverview.Handler.Resolve( + agendaPatterns, agendaExceptions, localDate, localDate); + + var memberNamesById = agendaPatterns + .Where(p => p.FamilyMember is not null) + .GroupBy(p => p.FamilyMemberId) + .ToDictionary(g => g.Key, g => g.First().FamilyMember!.DisplayName); + foreach (var ex in agendaExceptions) + { + if (ex.FamilyMember is not null) + { + memberNamesById[ex.FamilyMemberId] = ex.FamilyMember.DisplayName; + } + } + + var agendaItems = resolvedAgenda + .Where(r => r.IsAway) + .Select(r => + { + var name = memberNamesById.GetValueOrDefault(r.MemberId, "?"); + var detail = new List(); + if (!string.IsNullOrEmpty(r.Location)) detail.Add(r.Location!); + if (r.StartTime is { } start && r.EndTime is { } end) + { + detail.Add($"{start:HH\\:mm}โ€“{end:HH\\:mm}"); + } + else if (r.StartTime is { } onlyStart) + { + detail.Add($"desde las {onlyStart:HH\\:mm}"); + } + return new EmailTemplates.DigestItem( + $"{name} ยท {r.Label}", + detail.Count == 0 ? null : string.Join(" ยท ", detail)); + }) + .ToList(); + + // โ”€โ”€ School: holiday banner, bus pickup, extracurriculars where this + // member is the kid OR a caretaker (so the responsible adult always + // gets the "you're picking up Bob today" line in their digest). + var schoolItems = new List(); + + var holidayToday = await db.SchoolHolidays + .AsNoTracking() + .Where(h => h.FamilyId == familyId && h.StartDate <= localDate && h.EndDate >= localDate) + .Select(h => h.Label) + .FirstOrDefaultAsync(cancellationToken); + + if (holidayToday is not null) + { + schoolItems.Add(new EmailTemplates.DigestItem( + $"Festivo: {holidayToday}", "Hoy no hay cole.")); + } + else + { + // School day rows where this recipient is the kid OR a caretaker. + // We materialise both sides because for adults the digest doubles + // as their "to-do for the day". + var daySchedule = await db.SchoolDaySchedules + .AsNoTracking() + .Include(s => s.FamilyMember) + .Include(s => s.DropoffMember) + .Include(s => s.PickupMember) + .Where(s => s.FamilyMember!.FamilyId == familyId + && s.DayOfWeek == localDate.DayOfWeek + && (s.FamilyMemberId == recipient.MemberId + || s.DropoffMemberId == recipient.MemberId + || s.PickupMemberId == recipient.MemberId)) + .ToListAsync(cancellationToken); + + var dayExceptions = await db.SchoolDayExceptions + .AsNoTracking() + .Include(e => e.FamilyMember) + .Include(e => e.DropoffMember) + .Include(e => e.PickupMember) + .Where(e => e.FamilyId == familyId && e.Date == localDate + && (e.FamilyMemberId == recipient.MemberId + || e.DropoffMemberId == recipient.MemberId + || e.PickupMemberId == recipient.MemberId)) + .ToListAsync(cancellationToken); + + // Index exceptions by (kidId) so we can override the schedule. + var exceptionByKid = dayExceptions.ToDictionary(e => e.FamilyMemberId); + + // Profile defaults for the kids that show up today. Used to fall + // back when the exception only overrides one slot of the times. + var kidIds = daySchedule.Select(s => s.FamilyMemberId) + .Concat(dayExceptions.Select(e => e.FamilyMemberId)) + .Distinct() + .ToList(); + var profileTimes = kidIds.Count == 0 + ? [] + : await db.SchoolProfiles + .AsNoTracking() + .Where(p => kidIds.Contains(p.FamilyMemberId)) + .Select(p => new { p.FamilyMemberId, p.MorningTime, p.AfternoonTime }) + .ToListAsync(cancellationToken); + var defaultsByKid = profileTimes.ToDictionary( + p => p.FamilyMemberId, + p => (Morning: p.MorningTime, Afternoon: p.AfternoonTime)); + (TimeOnly? Morning, TimeOnly? Afternoon) DefaultTimes(Guid kidId) + => defaultsByKid.TryGetValue(kidId, out var t) ? t : (null, null); + + var daySeen = new HashSet(); + foreach (var s in daySchedule) + { + daySeen.Add(s.FamilyMemberId); + var defaults = DefaultTimes(s.FamilyMemberId); + if (exceptionByKid.TryGetValue(s.FamilyMemberId, out var ex)) + { + AppendSchoolDayItem(schoolItems, + s.FamilyMember!.DisplayName, + ex.IsCancelled, + ex.DropoffMember?.DisplayName ?? s.DropoffMember?.DisplayName, + ex.PickupMember?.DisplayName ?? s.PickupMember?.DisplayName, + ex.MorningTime ?? defaults.Morning, + ex.AfternoonTime ?? defaults.Afternoon); + } + else + { + AppendSchoolDayItem(schoolItems, + s.FamilyMember!.DisplayName, + isCancelled: false, + s.DropoffMember?.DisplayName, + s.PickupMember?.DisplayName, + defaults.Morning, + defaults.Afternoon); + } + } + // Exceptions for kids that don't have a schedule row today. + foreach (var ex in dayExceptions.Where(e => !daySeen.Contains(e.FamilyMemberId))) + { + var defaults = DefaultTimes(ex.FamilyMemberId); + AppendSchoolDayItem(schoolItems, + ex.FamilyMember!.DisplayName, + ex.IsCancelled, + ex.DropoffMember?.DisplayName, + ex.PickupMember?.DisplayName, + ex.MorningTime ?? defaults.Morning, + ex.AfternoonTime ?? defaults.Afternoon); + } + + // Extracurriculars today where the recipient is the kid OR a default/override caretaker. + var todaysExtras = await db.Extracurriculars + .AsNoTracking() + .Include(e => e.FamilyMember) + .Include(e => e.DefaultDropoffMember) + .Include(e => e.DefaultPickupMember) + .Include(e => e.Exceptions.Where(x => x.Date == localDate)) + .Where(e => e.FamilyId == familyId + && !e.IsArchived + && e.StartDate <= localDate + && (e.EndDate == null || e.EndDate >= localDate)) + .ToListAsync(cancellationToken); + + foreach (var activity in todaysExtras) + { + if ((activity.WeeklyDays & ToMask(localDate.DayOfWeek)) == DayOfWeekMask.None) continue; + + var ex = activity.Exceptions.FirstOrDefault(); + var dropoffId = ex?.DropoffMemberId ?? activity.DefaultDropoffMemberId; + var pickupId = ex?.PickupMemberId ?? activity.DefaultPickupMemberId; + var isCancelled = ex?.IsCancelled ?? false; + + var involves = activity.FamilyMemberId == recipient.MemberId + || dropoffId == recipient.MemberId + || pickupId == recipient.MemberId; + if (!involves) continue; + + if (isCancelled) + { + schoolItems.Add(new EmailTemplates.DigestItem( + activity.Name, + $"Cancelado ยท {activity.FamilyMember!.DisplayName}")); + continue; + } + + var time = $"{activity.StartTime:HH:mm}โ€“{activity.EndTime:HH:mm}"; + var dropoffName = await ResolveDisplayNameAsync(db, dropoffId, cancellationToken); + var pickupName = await ResolveDisplayNameAsync(db, pickupId, cancellationToken); + var detailParts = new List + { + activity.FamilyMember!.DisplayName, + time, + }; + if (!string.IsNullOrEmpty(activity.Location)) detailParts.Add(activity.Location!); + if (dropoffName is not null) detailParts.Add($"lleva {dropoffName}"); + if (pickupName is not null) detailParts.Add($"recoge {pickupName}"); + schoolItems.Add(new EmailTemplates.DigestItem(activity.Name, string.Join(" ยท ", detailParts))); + } + } + + // โ”€โ”€ Meals: today's planned lunch and dinner from the meal planner. + var mealSlots = await db.MealPlanSlots + .AsNoTracking() + .Where(s => s.FamilyId == familyId && s.Date == localDate) + .OrderBy(s => s.Slot) + .ToListAsync(cancellationToken); + + var mealItems = mealSlots + .Select(slot => + { + var courses = new[] { slot.FirstCourse, slot.SecondCourse } + .Where(c => !string.IsNullOrWhiteSpace(c)) + .ToArray(); + var title = slot.Slot == MealSlot.Lunch ? "Comida" : "Cena"; + var detail = courses.Length == 0 ? null : string.Join(" ยท ", courses); + return new EmailTemplates.DigestItem(title, detail); + }) + .Where(i => !string.IsNullOrEmpty(i.Detail)) + .ToList(); + + // โ”€โ”€ Birthdays today and tomorrow (any active member of the family). + var membersWithBirthday = await db.FamilyMembers + .AsNoTracking() + .Where(m => m.FamilyId == familyId && m.IsActive && m.BirthDate != null) + .Select(m => new { m.DisplayName, m.BirthDate }) + .ToListAsync(cancellationToken); + + var birthdayItems = new List(); + foreach (var m in membersWithBirthday) + { + var bd = m.BirthDate!.Value; + if (bd.Day == localDate.Day && bd.Month == localDate.Month) + { + var age = localDate.Year - bd.Year; + birthdayItems.Add(new EmailTemplates.DigestItem( + $"ยกHoy es el cumple de {m.DisplayName}!", + $"Cumple {age} aรฑos")); + } + else if (bd.Day == tomorrow.Day && bd.Month == tomorrow.Month) + { + var age = tomorrow.Year - bd.Year; + birthdayItems.Add(new EmailTemplates.DigestItem( + $"Maรฑana cumple {m.DisplayName}", + $"Cumplirรก {age} aรฑos")); + } + } + + // โ”€โ”€ Wall messages new since the last digest run (or last 24h if none). + var since = nowUtcMinusOneDay(); + var lastRun = await db.EmailDigestRuns + .AsNoTracking() + .Where(r => r.UserId == recipient.UserId && r.LocalDate < localDate) + .OrderByDescending(r => r.LocalDate) + .Select(r => (DateTimeOffset?)r.SentAt) + .FirstOrDefaultAsync(cancellationToken); + if (lastRun is { } lr && lr > since) since = lr; + + var newMessages = await db.WallMessages + .AsNoTracking() + .Where(w => w.FamilyId == familyId + && w.CreatedAt > since + && w.AuthorMemberId != recipient.MemberId) + .OrderByDescending(w => w.CreatedAt) + .Take(5) + .Select(w => new + { + w.Text, + AuthorName = w.AuthorMember!.DisplayName, + }) + .ToListAsync(cancellationToken); + + var wallItems = newMessages + .Select(w => new EmailTemplates.DigestItem(w.AuthorName, EmailTemplates.MakeSnippet(w.Text, 140))) + .ToList(); + + return new EmailTemplates.DigestContent( + taskItems, eventItems, agendaItems, mealItems, schoolItems, birthdayItems, wallItems); + } + + /// Build a "lleva X ยท recoge Y" line for the school section, branching on cancellation. + private static void AppendSchoolDayItem( + List sink, + string kidName, + bool isCancelled, + string? dropoffName, + string? pickupName, + TimeOnly? morningTime, + TimeOnly? afternoonTime) + { + if (isCancelled) + { + sink.Add(new EmailTemplates.DigestItem(kidName, "Hoy no hay cole")); + return; + } + var parts = new List(); + if (dropoffName is not null) + { + parts.Add(morningTime is { } m + ? $"Lleva {dropoffName} a las {m:HH\\:mm}" + : $"Lleva {dropoffName}"); + } + if (pickupName is not null) + { + parts.Add(afternoonTime is { } a + ? $"Recoge {pickupName} a las {a:HH\\:mm}" + : $"Recoge {pickupName}"); + } + if (parts.Count == 0 && afternoonTime is { } onlyTime) + { + parts.Add($"Sale a las {onlyTime:HH\\:mm}"); + } + sink.Add(new EmailTemplates.DigestItem( + kidName, + parts.Count == 0 ? "Sin asignar" : string.Join(" ยท ", parts))); + } + + private static async Task ResolveDisplayNameAsync( + ApplicationDbContext db, + Guid? memberId, + CancellationToken cancellationToken) + { + if (memberId is null) return null; + return await db.FamilyMembers + .Where(m => m.Id == memberId) + .Select(m => m.DisplayName) + .FirstOrDefaultAsync(cancellationToken); + } + + private static DayOfWeekMask ToMask(DayOfWeek dow) => dow switch + { + DayOfWeek.Monday => DayOfWeekMask.Monday, + DayOfWeek.Tuesday => DayOfWeekMask.Tuesday, + DayOfWeek.Wednesday => DayOfWeekMask.Wednesday, + DayOfWeek.Thursday => DayOfWeekMask.Thursday, + DayOfWeek.Friday => DayOfWeekMask.Friday, + DayOfWeek.Saturday => DayOfWeekMask.Saturday, + DayOfWeek.Sunday => DayOfWeekMask.Sunday, + _ => DayOfWeekMask.None, + }; + + private static DateTimeOffset nowUtcMinusOneDay() => DateTimeOffset.UtcNow.AddDays(-1); + + /// Compact carrier the digest builder needs about its recipient. + internal readonly record struct RecipientRow(Guid MemberId, string DisplayName, Guid UserId, string Email, string Language); +} diff --git a/src/FamilyNido.Api/Features/Notifications/EmailDispatchBackgroundService.cs b/src/FamilyNido.Api/Features/Notifications/EmailDispatchBackgroundService.cs new file mode 100644 index 0000000..adefd3e --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/EmailDispatchBackgroundService.cs @@ -0,0 +1,66 @@ +namespace FamilyNido.Api.Features.Notifications; + +/// +/// Drains the queue and hands each message +/// to the resolved . Each send runs in its own DI +/// scope so transient services (logger, options snapshots) line up correctly, +/// and exceptions in one send never block the next. +/// +public sealed class EmailDispatchBackgroundService : BackgroundService +{ + private readonly EmailDispatchService _dispatcher; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + /// Primary constructor. + public EmailDispatchBackgroundService( + EmailDispatchService dispatcher, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _dispatcher = dispatcher; + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await foreach (var message in _dispatcher.Reader.ReadAllAsync(stoppingToken)) + { + await SendOneAsync(message, stoppingToken); + } + } + catch (OperationCanceledException) + { + // Shutdown โ€” drop pending messages on the floor by design. + } + } + + private async Task SendOneAsync(EmailMessage message, CancellationToken cancellationToken) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var sender = scope.ServiceProvider.GetRequiredService(); + var result = await sender.SendAsync(message, cancellationToken); + if (!result.Delivered) + { + _logger.LogWarning( + "Email to {To} not delivered: {Reason}", + message.To, + result.ErrorReason ?? "unknown"); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Background email dispatch failed for {To}", message.To); + } + } +} diff --git a/src/FamilyNido.Api/Features/Notifications/EmailDispatchService.cs b/src/FamilyNido.Api/Features/Notifications/EmailDispatchService.cs new file mode 100644 index 0000000..18ea9ec --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/EmailDispatchService.cs @@ -0,0 +1,31 @@ +using System.Threading.Channels; + +namespace FamilyNido.Api.Features.Notifications; + +/// +/// Fire-and-forget queue for outgoing emails. Slices call +/// from request handlers without awaiting the actual send: the message lands +/// in an unbounded in-memory channel and a hosted service drains it on a +/// background scope. Pending messages are lost on restart โ€” that's the cost +/// of skipping persistence and is acceptable for non-critical notifications. +/// +public sealed class EmailDispatchService +{ + private readonly Channel _channel = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + /// Reader consumed by the background drainer. Internal coupling โ€” not for slice use. + public ChannelReader Reader => _channel.Reader; + + /// Enqueue a message. Returns immediately; never throws. + /// Composed email payload. + public void Queue(EmailMessage message) + { + // Channel is unbounded so TryWrite never fails โ€” the bool is ignored. + _channel.Writer.TryWrite(message); + } +} diff --git a/src/FamilyNido.Api/Features/Notifications/EmailTemplates.cs b/src/FamilyNido.Api/Features/Notifications/EmailTemplates.cs new file mode 100644 index 0000000..034c6e1 --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/EmailTemplates.cs @@ -0,0 +1,242 @@ +using System.Net; +using System.Text; +using FamilyNido.Domain.HouseholdTasks; + +namespace FamilyNido.Api.Features.Notifications; + +/// +/// HTML email templates rendered as raw interpolated strings โ€” small, no +/// dependency on a templating engine. Inputs are HTML-encoded at the point of +/// substitution; outputs target broad client compatibility (inline styles, +/// no CSS classes, table-free). +/// +/// +/// Every public method takes a lang BCP-47 tag (typically +/// ) and routes its +/// strings through so the email matches +/// the recipient's chosen frontend bundle. +/// +public static class EmailTemplates +{ + /// Branded subject line for the "task assigned" email. + /// Display name of the person who assigned the task. + /// Display name of the responsible (greeted in the body). + /// Task aggregate, used for title and dates. + /// Public origin used to build the in-app link. + /// BCP-47 language tag of the recipient. + public static (string Subject, string HtmlBody) TaskAssigned( + string actorName, + string recipientName, + HouseholdTask task, + string appBaseUrl, + string lang) + { + var T = (string key) => BackendLocalization.T(key, lang); + var pathPrefix = BackendLocalization.PathPrefix(lang); + + var dueLine = task.DueDate is { } dd + ? $"

{WebUtility.HtmlEncode(T("email.task-assigned.due-prefix") + BackendLocalization.FormatLongDate(dd.ToDateTime(TimeOnly.MinValue), lang))}.

" + : string.Empty; + + var description = string.IsNullOrWhiteSpace(task.Description) + ? string.Empty + : $"

{WebUtility.HtmlEncode(task.Description)}

"; + + var subject = $"{T("email.task-assigned.subject")}{task.Title}"; + var html = ShellHtml( + $@" +

{WebUtility.HtmlEncode(T("email.task-assigned.greeting"))} {WebUtility.HtmlEncode(recipientName)},

+

{WebUtility.HtmlEncode(actorName)} {WebUtility.HtmlEncode(T("email.task-assigned.body"))}

+
+

{WebUtility.HtmlEncode(task.Title)}

+ {description} + {dueLine} +
+

+ + {WebUtility.HtmlEncode(T("email.task-assigned.cta"))} + +

", + lang); + + return (subject, html); + } + + /// Subject + HTML for the "you were mentioned on the wall" email. + /// Author of the message/comment. + /// Person being notified. + /// Already-localised label of the context (e.g. "a wall message"). See . + /// Plain-text excerpt of the markdown source, already trimmed. + /// Public origin used to build the in-app link. + /// BCP-47 language tag of the recipient. + public static (string Subject, string HtmlBody) WallMention( + string actorName, + string recipientName, + string contextLabel, + string snippet, + string appBaseUrl, + string lang) + { + var T = (string key) => BackendLocalization.T(key, lang); + var pathPrefix = BackendLocalization.PathPrefix(lang); + + var subject = $"{actorName}{T("email.wall-mention.subject-suffix")}"; + var html = ShellHtml( + $@" +

{WebUtility.HtmlEncode(T("email.wall-mention.greeting"))} {WebUtility.HtmlEncode(recipientName)},

+

{WebUtility.HtmlEncode(actorName)} {WebUtility.HtmlEncode(T("email.wall-mention.body-prefix"))} {WebUtility.HtmlEncode(contextLabel)}:

+
+ {WebUtility.HtmlEncode(snippet)} +
+

+ + {WebUtility.HtmlEncode(T("email.wall-mention.cta"))} + +

", + lang); + + return (subject, html); + } + + /// Resolve the human label for a wall-mention context kind. + /// Either "message" or "comment". + /// Recipient locale. + public static string WallMentionContextLabel(string kind, string lang) => kind switch + { + "comment" => BackendLocalization.T("email.wall-mention.context.comment", lang), + _ => BackendLocalization.T("email.wall-mention.context.message", lang), + }; + + /// Item shown in the "your day" digest section of the morning email. + /// Title of the task / event. + /// Optional context line (time, role, locationโ€ฆ). + public sealed record DigestItem(string Title, string? Detail); + + /// Aggregate content displayed in a daily digest email. + /// Chores scheduled for the day in the family. + /// Calendar events of the day where the recipient appears. + /// Members away from home today (work, travel, regular activities). + /// Today's planned lunch and dinner from the meal planner. + /// Today's school logistics (holiday badge, bus pickup, extracurriculars). + /// Members whose birthday falls today or tomorrow. + /// Wall messages published since the last digest. + public sealed record DigestContent( + IReadOnlyList Tasks, + IReadOnlyList Events, + IReadOnlyList Agenda, + IReadOnlyList Meals, + IReadOnlyList School, + IReadOnlyList Birthdays, + IReadOnlyList WallMessages) + { + /// True when no section carries any item. + public bool IsEmpty => Tasks.Count == 0 + && Events.Count == 0 + && Agenda.Count == 0 + && Meals.Count == 0 + && School.Count == 0 + && Birthdays.Count == 0 + && WallMessages.Count == 0; + } + + /// Render the morning digest email. + /// Display name of the recipient. + /// Pre-built sections. + /// Public origin used to build the in-app link. + /// BCP-47 language tag of the recipient. + public static (string Subject, string HtmlBody) Digest( + string recipientName, + DigestContent content, + string appBaseUrl, + string lang) + { + var T = (string key) => BackendLocalization.T(key, lang); + + var subject = $"{T("email.digest.subject")} โ€” {BackendLocalization.FormatLongDate(DateTime.Now, lang)}"; + var sb = new StringBuilder(); + sb.Append($@"

{WebUtility.HtmlEncode(T("email.digest.greeting"))} {WebUtility.HtmlEncode(recipientName)}, {WebUtility.HtmlEncode(T("email.digest.intro"))}

"); + + AppendSection(sb, T("email.digest.section.tasks"), content.Tasks); + AppendSection(sb, T("email.digest.section.events"), content.Events); + AppendSection(sb, T("email.digest.section.agenda"), content.Agenda); + AppendSection(sb, T("email.digest.section.school"), content.School); + AppendSection(sb, T("email.digest.section.meals"), content.Meals); + AppendSection(sb, T("email.digest.section.birthdays"), content.Birthdays); + AppendSection(sb, T("email.digest.section.wall"), content.WallMessages); + + sb.Append($@" +

+ + {WebUtility.HtmlEncode(T("email.digest.cta"))} + +

"); + + return (subject, ShellHtml(sb.ToString(), lang)); + } + + private static void AppendSection(StringBuilder sb, string title, IReadOnlyList items) + { + if (items.Count == 0) return; + + sb.Append($@" +

{WebUtility.HtmlEncode(title)}

+
    "); + foreach (var item in items) + { + var detail = string.IsNullOrEmpty(item.Detail) + ? string.Empty + : $@" ยท {WebUtility.HtmlEncode(item.Detail)}"; + sb.Append($@"
  • {WebUtility.HtmlEncode(item.Title)}{detail}
  • "); + } + sb.Append("
"); + } + + /// Trim a markdown source down to a one-line preview suitable for an email snippet. + /// Raw user-typed markdown. + /// Hard cap (default 240 characters). + public static string MakeSnippet(string markdown, int maxLength = 240) + { + // Collapse whitespace then strip the obvious markdown noise so the + // snippet reads like a sentence in the email rather than literal source. + var sb = new StringBuilder(markdown.Length); + var lastWasSpace = false; + foreach (var ch in markdown) + { + if (char.IsWhiteSpace(ch)) + { + if (!lastWasSpace) sb.Append(' '); + lastWasSpace = true; + continue; + } + // Drop the most common decoration characters; leave words and punctuation. + if (ch is '*' or '_' or '`' or '#' or '>') continue; + sb.Append(ch); + lastWasSpace = false; + } + + var text = sb.ToString().Trim(); + if (text.Length <= maxLength) return text; + return text[..(maxLength - 1)] + "โ€ฆ"; + } + + private static string ShellHtml(string innerBody, string lang) + { + var htmlLang = lang.StartsWith("en", StringComparison.OrdinalIgnoreCase) ? "en" : "es"; + var footer = BackendLocalization.T("email.footer", lang); + return $@" + + +
+

+ ๐Ÿชบ + FamilyNido +

+ {innerBody} +

+ {WebUtility.HtmlEncode(footer)} +

+
+ +"; + } +} diff --git a/src/FamilyNido.Api/Features/Notifications/GetMyNotificationPreferences.cs b/src/FamilyNido.Api/Features/Notifications/GetMyNotificationPreferences.cs new file mode 100644 index 0000000..04c015d --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/GetMyNotificationPreferences.cs @@ -0,0 +1,53 @@ +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.Notifications; + +/// +/// Slice for GET /api/notifications/preferences. Returns the caller's +/// preferences row or, when missing, the implicit "everything on" defaults. +/// +public static class GetMyNotificationPreferences +{ + /// Query carries no payload โ€” the caller is the implicit subject. + public sealed record Query : IRequest>; + + /// Reads UserNotificationPreferences for the caller. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> HandleAsync(Query request, CancellationToken cancellationToken) + { + var user = await _userContext.GetUserAsync(cancellationToken); + if (user is null) + { + return ApplicationError.Forbidden("auth.unauthenticated", "Not signed in."); + } + + var prefs = await _db.UserNotificationPreferences + .AsNoTracking() + .FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken); + + // No row yet โ†’ treat as defaults; the row is created on first PUT. + return new NotificationPreferencesDto( + EmailEnabled: prefs?.EmailEnabled ?? true, + DigestEnabled: prefs?.DigestEnabled ?? true, + TaskAssignedEnabled: prefs?.TaskAssignedEnabled ?? true, + WallMentionEnabled: prefs?.WallMentionEnabled ?? true); + } + } +} diff --git a/src/FamilyNido.Api/Features/Notifications/IEmailSender.cs b/src/FamilyNido.Api/Features/Notifications/IEmailSender.cs new file mode 100644 index 0000000..b8df29a --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/IEmailSender.cs @@ -0,0 +1,24 @@ +namespace FamilyNido.Api.Features.Notifications; + +/// +/// Abstraction over outbound email delivery. Implementations never throw on +/// transport failures: they return a structured so +/// callers can decide whether to retry, fall back to "copy link", or log. +/// +public interface IEmailSender +{ + /// Sends one message. Always returns; never throws. + Task SendAsync(EmailMessage message, CancellationToken cancellationToken); +} + +/// One outbound email payload. +/// RFC 5322 mailbox of the recipient. +/// Plain-text subject line. +/// Pre-rendered HTML body. The sender attaches a plain-text alternative automatically. +public sealed record EmailMessage(string To, string Subject, string HtmlBody); + +/// Outcome of a send attempt. Always returned, never thrown. +/// True when the SMTP server accepted the message for delivery. +/// Optional message id reported by the SMTP server (when available). +/// Short machine-readable code when is false. +public sealed record EmailResult(bool Delivered, string? ProviderMessageId, string? ErrorReason); diff --git a/src/FamilyNido.Api/Features/Notifications/NotificationEndpoints.cs b/src/FamilyNido.Api/Features/Notifications/NotificationEndpoints.cs new file mode 100644 index 0000000..1ce73d6 --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/NotificationEndpoints.cs @@ -0,0 +1,53 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; + +namespace FamilyNido.Api.Features.Notifications; + +/// Endpoints exposing per-user notification preferences. +public static class NotificationEndpoints +{ + /// Registers GET/PUT /api/notifications/preferences. + public static IEndpointRouteBuilder MapNotificationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/notifications").WithTags("Notifications"); + + group.MapGet("/preferences", GetPreferencesAsync) + .RequireAuthorization(Policies.AuthenticatedUser); + + group.MapPut("/preferences", UpdatePreferencesAsync) + .RequireAuthorization(Policies.AuthenticatedUser); + + group.MapPost("/digest/me", SendMyDigestAsync) + .RequireAuthorization(Policies.AuthenticatedUser); + + return app; + } + + private static async Task SendMyDigestAsync(IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new SendMyDigest.Command(), ct); + return result.IsSuccess + ? Results.Ok(result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task GetPreferencesAsync(IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new GetMyNotificationPreferences.Query(), ct); + return result.IsSuccess + ? Results.Ok(result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task UpdatePreferencesAsync( + UpdateMyNotificationPreferences.Command command, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(command, ct); + return result.IsSuccess + ? Results.Ok(result.Value) + : result.Error.ToHttpResult(); + } +} diff --git a/src/FamilyNido.Api/Features/Notifications/NotificationPreferencesDto.cs b/src/FamilyNido.Api/Features/Notifications/NotificationPreferencesDto.cs new file mode 100644 index 0000000..beacbc5 --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/NotificationPreferencesDto.cs @@ -0,0 +1,12 @@ +namespace FamilyNido.Api.Features.Notifications; + +/// Wire shape returned by GET/PUT /api/notifications/preferences. +/// Master switch โ€” disables every email regardless of the others. +/// Daily morning digest with today's tasks/events/birthdays. +/// Email when assigned as the responsible of a new or edited task. +/// Email when mentioned via @ on the wall. +public sealed record NotificationPreferencesDto( + bool EmailEnabled, + bool DigestEnabled, + bool TaskAssignedEnabled, + bool WallMentionEnabled); diff --git a/src/FamilyNido.Api/Features/Notifications/NotificationService.cs b/src/FamilyNido.Api/Features/Notifications/NotificationService.cs new file mode 100644 index 0000000..3c28a03 --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/NotificationService.cs @@ -0,0 +1,215 @@ +using FamilyNido.Api.Options; +using FamilyNido.Domain.HouseholdTasks; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace FamilyNido.Api.Features.Notifications; + +/// +/// Translates domain events ("a task got a new responsible", "a member was +/// mentioned on the wall") into outbound email payloads and queues them +/// through . Centralising the lookup here +/// keeps the slices ignorant of preferences, templates and SMTP plumbing. +/// +/// +/// The service is scoped (it owns an ) and +/// is meant to be injected into request handlers. All methods return when the +/// message is enqueued โ€” actual delivery is handled asynchronously by +/// . +/// +public sealed class NotificationService +{ + private readonly ApplicationDbContext _db; + private readonly EmailDispatchService _dispatcher; + private readonly IOptionsMonitor _emailOptions; + private readonly ILogger _logger; + + /// Primary constructor. + public NotificationService( + ApplicationDbContext db, + EmailDispatchService dispatcher, + IOptionsMonitor emailOptions, + ILogger logger) + { + _db = db; + _dispatcher = dispatcher; + _emailOptions = emailOptions; + _logger = logger; + } + + /// + /// Notify the responsible of a task. Skips when actor==recipient (you don't + /// email yourself), when the recipient is not linked to a user, when the + /// user has no email configured, or when their preferences have either + /// the master switch or the per-type toggle disabled. + /// + /// Member newly assigned as responsible. + /// Member that performed the assignment (the caller). + /// Task aggregate, used for body content. + /// Cancellation token from the request scope. + public async Task NotifyTaskAssignedAsync( + Guid responsibleMemberId, + Guid actorMemberId, + HouseholdTask task, + CancellationToken cancellationToken) + { + if (responsibleMemberId == actorMemberId) + { + return; + } + + var recipient = await _db.FamilyMembers + .Where(m => m.Id == responsibleMemberId) + .Select(m => new + { + MemberDisplayName = m.DisplayName, + Email = m.User != null ? m.User.Email : null, + UserId = m.UserId, + Language = m.User != null ? m.User.PreferredLanguage : "es-ES", + }) + .FirstOrDefaultAsync(cancellationToken); + + if (recipient is null || string.IsNullOrEmpty(recipient.Email) || recipient.UserId is null) + { + return; + } + + if (!await IsAllowedAsync(recipient.UserId.Value, p => p.TaskAssignedEnabled, cancellationToken)) + { + return; + } + + var actorName = await ResolveDisplayNameAsync(actorMemberId, cancellationToken); + var (subject, html) = EmailTemplates.TaskAssigned( + actorName, + recipient.MemberDisplayName, + task, + _emailOptions.CurrentValue.AppBaseUrl, + recipient.Language); + + _dispatcher.Queue(new EmailMessage(recipient.Email, subject, html)); + } + + /// + /// Notify a set of mentioned members. The actor is filtered out (no + /// self-notify) and each remaining recipient is checked individually + /// against their preferences. + /// + /// Distinct member ids extracted from the post. + /// Author of the message/comment. + /// Either "message" or "comment"; the human label is resolved per recipient locale. + /// Plain-text snippet of the source for the email body. + /// Cancellation token from the request scope. + public async Task NotifyWallMentionAsync( + IReadOnlyList mentionedMemberIds, + Guid actorMemberId, + string contextKind, + string snippet, + CancellationToken cancellationToken) + { + var targets = mentionedMemberIds.Where(id => id != actorMemberId).Distinct().ToHashSet(); + if (targets.Count == 0) + { + return; + } + + var recipients = await _db.FamilyMembers + .Where(m => targets.Contains(m.Id)) + .Select(m => new + { + MemberDisplayName = m.DisplayName, + Email = m.User != null ? m.User.Email : null, + UserId = m.UserId, + Language = m.User != null ? m.User.PreferredLanguage : "es-ES", + }) + .ToListAsync(cancellationToken); + + var userIds = recipients + .Where(r => r.UserId is not null) + .Select(r => r.UserId!.Value) + .ToList(); + + var prefs = new Dictionary(); + if (userIds.Count > 0) + { + var rows = await _db.UserNotificationPreferences + .AsNoTracking() + .Where(p => userIds.Contains(p.UserId)) + .Select(p => new { p.UserId, p.EmailEnabled, p.WallMentionEnabled }) + .ToListAsync(cancellationToken); + + foreach (var row in rows) + { + prefs[row.UserId] = new UserNotificationToggles(row.EmailEnabled, row.WallMentionEnabled); + } + } + + var actorName = await ResolveDisplayNameAsync(actorMemberId, cancellationToken); + var baseUrl = _emailOptions.CurrentValue.AppBaseUrl; + + foreach (var r in recipients) + { + if (string.IsNullOrEmpty(r.Email) || r.UserId is null) + { + continue; + } + + // Default to enabled when there is no row yet โ€” a brand-new user + // gets useful notifications until they explicitly turn them off. + var toggles = prefs.TryGetValue(r.UserId.Value, out var t) + ? t + : new UserNotificationToggles(EmailEnabled: true, ChannelEnabled: true); + + if (!toggles.EmailEnabled || !toggles.ChannelEnabled) + { + continue; + } + + var contextLabel = EmailTemplates.WallMentionContextLabel(contextKind, r.Language); + var (subject, html) = EmailTemplates.WallMention( + actorName, + r.MemberDisplayName, + contextLabel, + snippet, + baseUrl, + r.Language); + + _dispatcher.Queue(new EmailMessage(r.Email, subject, html)); + } + } + + private async Task IsAllowedAsync( + Guid userId, + Func channel, + CancellationToken cancellationToken) + { + var prefs = await _db.UserNotificationPreferences + .AsNoTracking() + .FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + + // Defaults: everything on. Master switch wins. + if (prefs is null) + { + return true; + } + + return prefs.EmailEnabled && channel(prefs); + } + + private async Task ResolveDisplayNameAsync(Guid memberId, CancellationToken cancellationToken) + { + var name = await _db.FamilyMembers + .Where(m => m.Id == memberId) + .Select(m => m.DisplayName) + .FirstOrDefaultAsync(cancellationToken); + + // Fallback used inside email greetings when the actor row was deleted + // mid-flight. Stays in Spanish โ€” the recipient locale isn't known here + // and the email template will still localize the surrounding sentence. + return string.IsNullOrEmpty(name) ? "Alguien de la familia" : name; + } + + /// Compact prefs view to avoid loading the whole entity per recipient. + private readonly record struct UserNotificationToggles(bool EmailEnabled, bool ChannelEnabled); +} diff --git a/src/FamilyNido.Api/Features/Notifications/NullEmailSender.cs b/src/FamilyNido.Api/Features/Notifications/NullEmailSender.cs new file mode 100644 index 0000000..b197c9b --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/NullEmailSender.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; + +namespace FamilyNido.Api.Features.Notifications; + +/// +/// Stand-in implementation registered when Email:Enabled=false. Logs +/// the would-be send at information level (not warning โ€” this is a chosen +/// state, not an error) and returns an unsuccessful +/// so the caller falls back to "copy link" UX. +/// +public sealed class NullEmailSender : IEmailSender +{ + private readonly ILogger _logger; + + /// Primary constructor. + public NullEmailSender(ILogger logger) => _logger = logger; + + /// + public Task SendAsync(EmailMessage message, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Email disabled; would have sent {Subject} to {To}", + message.Subject, + message.To); + + return Task.FromResult(new EmailResult(Delivered: false, ProviderMessageId: null, ErrorReason: "email_disabled")); + } +} diff --git a/src/FamilyNido.Api/Features/Notifications/SendMyDigest.cs b/src/FamilyNido.Api/Features/Notifications/SendMyDigest.cs new file mode 100644 index 0000000..d869ba5 --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/SendMyDigest.cs @@ -0,0 +1,108 @@ +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.Extensions.Options; + +namespace FamilyNido.Api.Features.Notifications; + +/// +/// Slice for POST /api/notifications/digest/me. Sends a "today" digest +/// email to the calling user only, useful for previewing the template +/// without waiting for the morning scheduler. Reuses +/// so the manual trigger and the scheduled tick render the same email. +/// +/// +/// Unlike the scheduled run, this slice does not insert an +/// row. That keeps it +/// idempotent in the "I want to preview again" sense and prevents the +/// scheduled tick from skipping today's real digest because someone hit +/// the preview button. The email goes only to the requesting user, so a +/// preview never spams the rest of the family. +/// +public static class SendMyDigest +{ + /// Empty command โ€” the recipient is whoever is calling. + public sealed record Command : IRequest>; + + /// Result returned to the front so it can confirm the send. + /// Address the email was queued to. + /// True when the digest had nothing to report (no email queued). + public sealed record Response(string Email, bool IsEmpty); + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly EmailDispatchService _dispatcher; + private readonly IOptions _emailOptions; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + EmailDispatchService dispatcher, + IOptions emailOptions, + TimeProvider timeProvider) + { + _db = db; + _userContext = userContext; + _dispatcher = dispatcher; + _emailOptions = emailOptions; + _timeProvider = timeProvider; + } + + /// + public async Task> 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 (string.IsNullOrEmpty(current.User.Email)) + { + return ApplicationError.Validation("digest.no_email", "Tu cuenta no tiene email asociado."); + } + + // Resolve the family timezone the same way the scheduler does so the + // "today" window matches what the morning email would cover. + TimeZoneInfo tz; + try + { + tz = TimeZoneInfo.FindSystemTimeZoneById(current.Family.TimeZone); + } + catch (TimeZoneNotFoundException) + { + tz = TimeZoneInfo.Utc; + } + var localDate = DateOnly.FromDateTime( + TimeZoneInfo.ConvertTime(_timeProvider.GetUtcNow(), tz).DateTime); + + var recipient = new EmailDigestBackgroundService.RecipientRow( + MemberId: current.Member.Id, + DisplayName: current.Member.DisplayName, + UserId: current.User.Id, + Email: current.User.Email, + Language: current.User.PreferredLanguage); + + var content = await EmailDigestBackgroundService.BuildContentAsync( + _db, current.Family.Id, recipient, localDate, tz, cancellationToken); + + if (content.IsEmpty) + { + return new Response(current.User.Email, IsEmpty: true); + } + + var (subject, html) = EmailTemplates.Digest( + current.Member.DisplayName, content, _emailOptions.Value.AppBaseUrl, current.User.PreferredLanguage); + _dispatcher.Queue(new EmailMessage(current.User.Email, $"[Preview] {subject}", html)); + + return new Response(current.User.Email, IsEmpty: false); + } + } +} diff --git a/src/FamilyNido.Api/Features/Notifications/SmtpEmailSender.cs b/src/FamilyNido.Api/Features/Notifications/SmtpEmailSender.cs new file mode 100644 index 0000000..a7cf65f --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/SmtpEmailSender.cs @@ -0,0 +1,128 @@ +using FamilyNido.Api.Options; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace FamilyNido.Api.Features.Notifications; + +/// +/// MailKit-backed SMTP sender. Talks to a generic relay configured by +/// : Brevo, Gmail with an app password, mailcow, +/// a corporate MTA, etc. +/// +/// +/// Connection posture is decided by : +/// +/// true โ†’ STARTTLS (port 587 submission). The connection opens plain and is upgraded. +/// false โ†’ MailKit's Auto: implicit TLS on 465, plain on 25. +/// +/// Authentication is skipped when is +/// empty (useful for trusted-network relays). All transport failures are +/// caught and converted into with +/// Delivered=false; callers never see exceptions from this layer. +/// +public sealed class SmtpEmailSender : IEmailSender +{ + private readonly IOptionsMonitor _options; + private readonly ILogger _logger; + + /// Primary constructor. + public SmtpEmailSender(IOptionsMonitor options, ILogger logger) + { + _options = options; + _logger = logger; + } + + /// + public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken) + { + var settings = _options.CurrentValue; + + if (string.IsNullOrWhiteSpace(settings.SmtpHost)) + { + _logger.LogWarning("SMTP host not configured; cannot send {Subject} to {To}", message.Subject, message.To); + return new EmailResult(false, null, "smtp_host_missing"); + } + + var mime = BuildMimeMessage(settings, message); + + try + { + using var client = new SmtpClient(); + + var secureSocket = settings.SmtpUseStartTls + ? SecureSocketOptions.StartTls + : SecureSocketOptions.Auto; + + await client.ConnectAsync(settings.SmtpHost, settings.SmtpPort, secureSocket, cancellationToken); + + if (!string.IsNullOrEmpty(settings.SmtpUsername)) + { + await client.AuthenticateAsync(settings.SmtpUsername, settings.SmtpPassword, cancellationToken); + } + + // MailKit returns the server's response string; we just preserve a + // truncated form as ProviderMessageId for traceability. + var serverResponse = await client.SendAsync(mime, cancellationToken); + await client.DisconnectAsync(quit: true, cancellationToken); + + return new EmailResult(true, Truncate(serverResponse, 120), null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning( + ex, + "SMTP send failed for {To} via {Host}:{Port}", + message.To, + settings.SmtpHost, + settings.SmtpPort); + + return new EmailResult(false, null, ClassifyError(ex)); + } + } + + private static MimeMessage BuildMimeMessage(EmailOptions settings, EmailMessage message) + { + var mime = new MimeMessage(); + + if (MailboxAddress.TryParse(settings.From, out var from)) + { + mime.From.Add(from); + } + else + { + // Fall back to a structured address so we never produce an unparseable + // From header โ€” better to ship "noreply" than to fail the whole send. + mime.From.Add(new MailboxAddress("FamilyNido", settings.From)); + } + + mime.To.Add(MailboxAddress.Parse(message.To)); + mime.Subject = message.Subject; + + // HTML-only body. Modern mail clients render it; text-only clients + // see a tags-stripped fallback that MailKit produces from the part + // structure. We avoid building a hand-rolled plain alternative until + // we have a real template โ€” the invitation email is short enough that + // even tag-stripped renders look fine. + var builder = new BodyBuilder { HtmlBody = message.HtmlBody }; + mime.Body = builder.ToMessageBody(); + + return mime; + } + + private static string ClassifyError(Exception ex) => ex switch + { + AuthenticationException => "smtp_auth_failed", + SslHandshakeException => "smtp_tls_failed", + SmtpCommandException smtpEx => $"smtp_command_{(int)smtpEx.StatusCode}", + SmtpProtocolException => "smtp_protocol", + _ => "smtp_unknown", + }; + + private static string Truncate(string? value, int max) + => string.IsNullOrEmpty(value) + ? string.Empty + : value.Length <= max ? value : value[..max]; +} diff --git a/src/FamilyNido.Api/Features/Notifications/UpdateMyNotificationPreferences.cs b/src/FamilyNido.Api/Features/Notifications/UpdateMyNotificationPreferences.cs new file mode 100644 index 0000000..b0f86c8 --- /dev/null +++ b/src/FamilyNido.Api/Features/Notifications/UpdateMyNotificationPreferences.cs @@ -0,0 +1,69 @@ +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 Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Notifications; + +/// +/// Slice for PUT /api/notifications/preferences. Upserts the caller's +/// preferences row โ€” creates it on first save, then updates in place. +/// +public static class UpdateMyNotificationPreferences +{ + /// Command carries the full set of toggles (replace, not patch). + public sealed record Command( + bool EmailEnabled, + bool DigestEnabled, + bool TaskAssignedEnabled, + bool WallMentionEnabled) : IRequest>; + + /// Performs the upsert. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> HandleAsync(Command request, CancellationToken cancellationToken) + { + var user = await _userContext.GetUserAsync(cancellationToken); + if (user is null) + { + return ApplicationError.Forbidden("auth.unauthenticated", "Not signed in."); + } + + var prefs = await _db.UserNotificationPreferences + .FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken); + + if (prefs is null) + { + prefs = new UserNotificationPreferences { UserId = user.Id }; + _db.UserNotificationPreferences.Add(prefs); + } + + prefs.EmailEnabled = request.EmailEnabled; + prefs.DigestEnabled = request.DigestEnabled; + prefs.TaskAssignedEnabled = request.TaskAssignedEnabled; + prefs.WallMentionEnabled = request.WallMentionEnabled; + + await _db.SaveChangesAsync(cancellationToken); + + return new NotificationPreferencesDto( + prefs.EmailEnabled, + prefs.DigestEnabled, + prefs.TaskAssignedEnabled, + prefs.WallMentionEnabled); + } + } +} diff --git a/src/FamilyNido.Api/Features/PublicApi/CreateTask.cs b/src/FamilyNido.Api/Features/PublicApi/CreateTask.cs new file mode 100644 index 0000000..6cf4cf6 --- /dev/null +++ b/src/FamilyNido.Api/Features/PublicApi/CreateTask.cs @@ -0,0 +1,169 @@ +using System.Security.Claims; +using FamilyNido.Api.Features.Integrations; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.HouseholdTasks; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.PublicApi; + +/// +/// Slice for POST /api/v1/tasks. Authenticated via an integration API key +/// (); creates a +/// in the family the token belongs to. The caller +/// (n8n, IFTTT, an iOS shortcut, โ€ฆ) decides the title, category and points +/// instead of the server hard-coding a per-appliance taxonomy. +/// +public static class CreateTask +{ + /// Request body. All non-required fields use safe defaults. + /// Required, max 200 chars. + /// Optional, max 50 chars. Defaults to the domain default ("General"). + /// Optional, 0..100 (clamped by validator). Defaults to the domain default (5). + /// Optional target date; ignored when is true. + /// Optional; when true, the task is floating (no fixed date). + /// Optional; must belong to the same family as the token. + /// Optional; when true, skips creation if a pending task with the same title already exists. + public sealed record Command( + string Title, + string? Category, + int? Points, + DateOnly? DueDate, + bool IsFloating, + Guid? ResponsibleMemberId, + bool Deduplicate) : IRequest>; + + /// Response body returned to the integration caller. + /// True when a brand-new task row was inserted. + /// Optional machine-readable detail when is false. + /// Id of the (existing or new) task. + /// Human-readable task title. + public sealed record Response(bool Created, string? Reason, Guid TaskId, string Title); + + /// Input validation. Keeps the handler focused on business rules. + public sealed class Validator : AbstractValidator + { + /// Creates the validator. + public Validator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(200); + + RuleFor(x => x.Category) + .MaximumLength(50) + .When(x => x.Category is not null); + + RuleFor(x => x.Points) + .InclusiveBetween(0, 100) + .When(x => x.Points.HasValue); + } + } + + /// Handler: validates the family/member, optionally dedups, then inserts. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TimeProvider _timeProvider; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + IHttpContextAccessor httpContextAccessor, + TimeProvider timeProvider) + { + _db = db; + _httpContextAccessor = httpContextAccessor; + _timeProvider = timeProvider; + } + + /// + public async Task> HandleAsync(Command request, CancellationToken cancellationToken) + { + var principal = _httpContextAccessor.HttpContext?.User + ?? throw new InvalidOperationException("HttpContext is required for integration auth resolution."); + + // Claims are stamped by IntegrationApiKeyAuthenticationHandler on + // every successful API-key authentication. Their absence here would + // mean the endpoint was wired up with the wrong policy. + var familyIdClaim = principal.FindFirstValue(IntegrationClaimTypes.FamilyId); + var authorMemberIdClaim = principal.FindFirstValue(IntegrationClaimTypes.AuthorMemberId); + if (!Guid.TryParse(familyIdClaim, out var familyId) + || !Guid.TryParse(authorMemberIdClaim, out var authorMemberId)) + { + return ApplicationError.Forbidden( + "integration.bad_principal", + "Integration principal is missing required claims."); + } + + // Responsible member, when supplied, must belong to the same family + // as the token. Otherwise an integration in family A could pin a + // chore on a member of family B โ€” the integration auth scope is + // single-family on purpose. + if (request.ResponsibleMemberId is { } responsibleId) + { + var responsibleExists = await _db.FamilyMembers + .AsNoTracking() + .AnyAsync( + m => m.Id == responsibleId && m.FamilyId == familyId, + cancellationToken); + if (!responsibleExists) + { + return ApplicationError.Validation( + "public_api.responsible_member_not_in_family", + "responsibleMemberId does not belong to the token's family."); + } + } + + // Opt-in dedup: same rule as the legacy HA endpoint, now exposed as + // a flag so generic callers can choose whether they want it. A task + // counts as "still pending" when it has no completions and isn't + // archived; floating chores graduate forever on their first + // completion, so this is the right pending-now signal. + if (request.Deduplicate) + { + var pending = await _db.HouseholdTasks + .Where(t => t.FamilyId == familyId + && !t.IsArchived + && t.Title == request.Title + && !t.Completions.Any()) + .OrderByDescending(t => t.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + if (pending is not null) + { + return new Response(false, "already-pending", pending.Id, pending.Title); + } + } + + var today = DateOnly.FromDateTime(_timeProvider.GetLocalNow().Date); + + // Floating tasks: no fixed DueDate, recurrence forced to None so the + // floating "always pending" semantics aren't crossed with a weekly + // pattern. Non-floating tasks default DueDate to today when the + // caller didn't supply one, which matches the legacy HA behaviour. + var task = new HouseholdTask + { + FamilyId = familyId, + Title = request.Title, + Category = string.IsNullOrWhiteSpace(request.Category) ? "General" : request.Category, + Points = request.Points ?? 5, + Recurrence = RecurrenceMode.None, + IsFloating = request.IsFloating, + StartDate = today, + DueDate = request.IsFloating ? null : (request.DueDate ?? today), + CreatedByMemberId = authorMemberId, + ResponsibleMemberId = request.ResponsibleMemberId, + }; + + _db.HouseholdTasks.Add(task); + await _db.SaveChangesAsync(cancellationToken); + + return new Response(true, null, task.Id, task.Title); + } + } +} diff --git a/src/FamilyNido.Api/Features/PublicApi/PublicApiEndpoints.cs b/src/FamilyNido.Api/Features/PublicApi/PublicApiEndpoints.cs new file mode 100644 index 0000000..3d8dec3 --- /dev/null +++ b/src/FamilyNido.Api/Features/PublicApi/PublicApiEndpoints.cs @@ -0,0 +1,55 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; +using FluentValidation; + +namespace FamilyNido.Api.Features.PublicApi; + +/// +/// Endpoints exposing the versioned public API surface (machine-to-machine). +/// Anything under /api/v1/** is reachable by integrations holding a +/// valid X-Api-Key / Authorization: Bearer โ€ฆ โ€” never by cookie +/// sessions. Versioned from day 1 so a future breaking change can land at +/// /v2 without disturbing existing integrators. +/// +public static class PublicApiEndpoints +{ + /// Rate-limit policy name applied to every public-api route. + public const string RateLimitPolicy = "public-api"; + + /// Registers /api/v1/** routes. + public static IEndpointRouteBuilder MapPublicApiEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1") + .WithTags("PublicApi") + .RequireAuthorization(Policies.Integration) + .RequireRateLimiting(RateLimitPolicy); + + group.MapPost("/tasks", CreateTaskAsync); + + return app; + } + + private static async Task CreateTaskAsync( + CreateTask.Command command, + IMediator mediator, + IValidator validator, + CancellationToken ct) + { + var problem = await validator.ValidateOrProblemAsync(command, ct); + if (problem is not null) + { + return problem; + } + + var result = await mediator.SendAsync(command, ct); + if (!result.IsSuccess) + { + return result.Error.ToHttpResult(); + } + + return result.Value.Created + ? Results.Created($"/api/household-tasks/{result.Value.TaskId}", result.Value) + : Results.Ok(result.Value); + } +} diff --git a/src/FamilyNido.Api/Features/School/AddExtracurricular.cs b/src/FamilyNido.Api/Features/School/AddExtracurricular.cs new file mode 100644 index 0000000..d614981 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/AddExtracurricular.cs @@ -0,0 +1,120 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.HouseholdTasks; +using FamilyNido.Domain.School; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.School; + +/// Slice for POST /api/school/extracurriculars. +public static class AddExtracurricular +{ + /// Command for a new extracurricular row. + public sealed record Command( + Guid MemberId, + string Name, + string? Location, + string? ContactPhone, + DayOfWeekMask WeeklyDays, + TimeOnly StartTime, + TimeOnly EndTime, + DateOnly StartDate, + DateOnly? EndDate, + Guid? DefaultDropoffMemberId, + Guid? DefaultPickupMemberId, + string? Notes) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(120); + RuleFor(x => x.Location).MaximumLength(160); + RuleFor(x => x.ContactPhone).MaximumLength(40); + RuleFor(x => x.Notes).MaximumLength(2000); + RuleFor(x => x.WeeklyDays).NotEqual(DayOfWeekMask.None) + .WithMessage("Pick at least one weekday."); + RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime) + .WithMessage("End time must be after start time."); + RuleFor(x => x.EndDate) + .GreaterThanOrEqualTo(x => x.StartDate) + .When(x => x.EndDate is not null); + } + } + + /// Persists the row after validating member references. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 ids = new HashSet { request.MemberId }; + if (request.DefaultDropoffMemberId is { } d) ids.Add(d); + if (request.DefaultPickupMemberId is { } p) ids.Add(p); + + var found = await _db.FamilyMembers + .Where(m => ids.Contains(m.Id) && m.FamilyId == current.Family.Id) + .Select(m => m.Id) + .ToListAsync(cancellationToken); + + if (found.Count != ids.Count) + { + return ApplicationError.Validation( + "school.extracurricular.unknown_member", + "One or more referenced members are not part of this family."); + } + + var entry = new Extracurricular + { + FamilyId = current.Family.Id, + FamilyMemberId = request.MemberId, + Name = request.Name.Trim(), + Location = Trim(request.Location), + ContactPhone = Trim(request.ContactPhone), + WeeklyDays = request.WeeklyDays, + StartTime = request.StartTime, + EndTime = request.EndTime, + StartDate = request.StartDate, + EndDate = request.EndDate, + DefaultDropoffMemberId = request.DefaultDropoffMemberId, + DefaultPickupMemberId = request.DefaultPickupMemberId, + Notes = Trim(request.Notes), + }; + + _db.Extracurriculars.Add(entry); + await _db.SaveChangesAsync(cancellationToken); + + return ToDto(entry); + } + + private static string? Trim(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + internal static ExtracurricularDto ToDto(Extracurricular e) => new( + e.Id, e.FamilyMemberId, e.Name, e.Location, e.ContactPhone, + e.WeeklyDays, e.StartTime, e.EndTime, e.StartDate, e.EndDate, + e.DefaultDropoffMemberId, e.DefaultPickupMemberId, e.Notes, e.IsArchived); + } +} diff --git a/src/FamilyNido.Api/Features/School/AddSchoolHoliday.cs b/src/FamilyNido.Api/Features/School/AddSchoolHoliday.cs new file mode 100644 index 0000000..b1d9620 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/AddSchoolHoliday.cs @@ -0,0 +1,63 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.School; +using FamilyNido.Persistence; +using FluentValidation; + +namespace FamilyNido.Api.Features.School; + +/// Slice for POST /api/school/holidays. +public static class AddSchoolHoliday +{ + /// Command for a new holiday range. + public sealed record Command(DateOnly StartDate, DateOnly EndDate, string Label) : IRequest>; + + /// Validation: start <= end and a label of reasonable length. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Label).NotEmpty().MaximumLength(120); + RuleFor(x => x.EndDate).GreaterThanOrEqualTo(x => x.StartDate); + } + } + + /// Persists the holiday inside the caller's family. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 = new SchoolHoliday + { + FamilyId = current.Family.Id, + StartDate = request.StartDate, + EndDate = request.EndDate, + Label = request.Label.Trim(), + }; + _db.SchoolHolidays.Add(entry); + await _db.SaveChangesAsync(cancellationToken); + + return new SchoolHolidayDto(entry.Id, entry.StartDate, entry.EndDate, entry.Label); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/ArchiveExtracurricular.cs b/src/FamilyNido.Api/Features/School/ArchiveExtracurricular.cs new file mode 100644 index 0000000..676f1f2 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/ArchiveExtracurricular.cs @@ -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.School; + +/// +/// Slice for PATCH /api/school/extracurriculars/{id}/archive. Marks +/// the activity as archived (soft delete) so the history of past courses is +/// preserved. Re-call to flip back via the +/// flag. +/// +public static class ArchiveExtracurricular +{ + /// Command to flip the archive flag. + public sealed record Command(Guid Id, bool IsArchived) : IRequest>; + + /// Updates the flag after family-scope check. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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.Extracurriculars.FirstOrDefaultAsync(e => e.Id == request.Id, cancellationToken); + if (entry is null || entry.FamilyId != current.Family.Id) + { + return ApplicationError.NotFound("school.extracurricular.not_found", $"Activity {request.Id} not found."); + } + + entry.IsArchived = request.IsArchived; + await _db.SaveChangesAsync(cancellationToken); + return AddExtracurricular.Handler.ToDto(entry); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/DeleteExtracurricular.cs b/src/FamilyNido.Api/Features/School/DeleteExtracurricular.cs new file mode 100644 index 0000000..84f662d --- /dev/null +++ b/src/FamilyNido.Api/Features/School/DeleteExtracurricular.cs @@ -0,0 +1,56 @@ +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.School; + +/// +/// Slice for DELETE /api/school/extracurriculars/{id}. Hard-deletes +/// the row along with its exceptions (cascade). Reserve for clean-up; the +/// usual lifecycle ends with . +/// +public static class DeleteExtracurricular +{ + /// Command identifying the row. + public sealed record Command(Guid Id) : IRequest>; + + /// Empty success payload. + public sealed record Unit; + + /// Removes the row after verifying family scope. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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.Extracurriculars.FirstOrDefaultAsync(e => e.Id == request.Id, cancellationToken); + if (entry is null || entry.FamilyId != current.Family.Id) + { + return ApplicationError.NotFound("school.extracurricular.not_found", $"Activity {request.Id} not found."); + } + + _db.Extracurriculars.Remove(entry); + await _db.SaveChangesAsync(cancellationToken); + return new Unit(); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/DeleteSchoolHoliday.cs b/src/FamilyNido.Api/Features/School/DeleteSchoolHoliday.cs new file mode 100644 index 0000000..b27645c --- /dev/null +++ b/src/FamilyNido.Api/Features/School/DeleteSchoolHoliday.cs @@ -0,0 +1,52 @@ +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.School; + +/// Slice for DELETE /api/school/holidays/{id}. +public static class DeleteSchoolHoliday +{ + /// Command identifying the holiday row. + public sealed record Command(Guid Id) : IRequest>; + + /// Empty success payload. + public sealed record Unit; + + /// Removes the row after verifying family scope. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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.SchoolHolidays.FirstOrDefaultAsync(h => h.Id == request.Id, cancellationToken); + if (entry is null || entry.FamilyId != current.Family.Id) + { + return ApplicationError.NotFound("school.holiday.not_found", $"Holiday {request.Id} not found."); + } + + _db.SchoolHolidays.Remove(entry); + await _db.SaveChangesAsync(cancellationToken); + return new Unit(); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/ExtracurricularDtos.cs b/src/FamilyNido.Api/Features/School/ExtracurricularDtos.cs new file mode 100644 index 0000000..cbe03b2 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/ExtracurricularDtos.cs @@ -0,0 +1,73 @@ +using FamilyNido.Domain.HouseholdTasks; + +namespace FamilyNido.Api.Features.School; + +/// Wire shape of an after-school activity. +/// Stable id. +/// Kid attending. +/// Activity name. +/// Optional location. +/// Optional contact phone (academy / coach / centre). +/// Bitmask of weekdays the activity occurs. +/// Local start time. +/// Local end time. +/// First date the activity runs. +/// Last date the activity runs (open-ended when null). +/// Default drop-off caretaker. +/// Default pick-up caretaker. +/// Free-form notes. +/// Soft-archive flag. +public sealed record ExtracurricularDto( + Guid Id, + Guid MemberId, + string Name, + string? Location, + string? ContactPhone, + DayOfWeekMask WeeklyDays, + TimeOnly StartTime, + TimeOnly EndTime, + DateOnly StartDate, + DateOnly? EndDate, + Guid? DefaultDropoffMemberId, + Guid? DefaultPickupMemberId, + string? Notes, + bool IsArchived); + +/// Wire shape of a per-date exception. +public sealed record ExtracurricularExceptionDto( + Guid Id, + Guid ExtracurricularId, + DateOnly Date, + bool IsCancelled, + Guid? DropoffMemberId, + Guid? PickupMemberId, + string? Notes); + +/// Per-day resolved instance shown in dashboard / weekly view. +/// Source activity id. +/// Kid attending. +/// Date. +/// Local start (from the activity, never overridden in v1). +/// Local end (same). +/// Activity name. +/// Location. +/// Contact phone. +/// Resolved drop-off caretaker (override โ†’ default โ†’ null). +/// Resolved pick-up caretaker (override โ†’ default โ†’ null). +/// True when cancelled by exception or holiday. +/// Holiday label when cancelled by a holiday range. +/// Override note when applicable. +public sealed record ResolvedExtracurricularDto( + Guid ExtracurricularId, + Guid MemberId, + DateOnly Date, + TimeOnly StartTime, + TimeOnly EndTime, + string Name, + string? Location, + string? ContactPhone, + Guid? DropoffMemberId, + Guid? PickupMemberId, + bool IsCancelled, + string? HolidayLabel, + string? Notes); diff --git a/src/FamilyNido.Api/Features/School/GetSchoolOverview.cs b/src/FamilyNido.Api/Features/School/GetSchoolOverview.cs new file mode 100644 index 0000000..093ad59 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/GetSchoolOverview.cs @@ -0,0 +1,269 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.HouseholdTasks; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.School; + +/// +/// Slice for GET /api/school/overview?from=YYYY-MM-DD&to=YYYY-MM-DD. +/// Returns the persisted weekly schedule, all per-date exceptions, all +/// holidays intersecting the range, plus a day-by-day "resolved" view that +/// merges those layers in priority order: holiday > exception > schedule. +/// +public static class GetSchoolOverview +{ + /// Query carrying the inclusive [From, To] range. + public sealed record Query(DateOnly From, DateOnly To) : IRequest>; + + /// Composes the overview DTO. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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."); + } + + if (request.To < request.From) + { + return ApplicationError.Validation("school.overview.bad_range", "End date must be on or after the start date."); + } + + var familyId = current.Family.Id; + + // โ”€โ”€ Persisted weekly schedule per kid (drop-off + pick-up). + var scheduleRows = await _db.SchoolDaySchedules + .AsNoTracking() + .Include(s => s.FamilyMember) + .Where(s => s.FamilyMember!.FamilyId == familyId) + .Select(s => new { s.FamilyMemberId, s.DayOfWeek, s.DropoffMemberId, s.PickupMemberId }) + .ToListAsync(cancellationToken); + + var scheduleByKid = scheduleRows + .GroupBy(r => r.FamilyMemberId) + .Select(g => new KidScheduleDto( + g.Key, + g.OrderBy(r => r.DayOfWeek) + .Select(r => new SchoolDayScheduleSlotDto(r.DayOfWeek, r.DropoffMemberId, r.PickupMemberId)) + .ToList())) + .ToList(); + + // Per-(kid, weekday) lookup for the resolution loop below. + var scheduleLookup = scheduleRows + .ToDictionary( + r => (r.FamilyMemberId, r.DayOfWeek), + r => (r.DropoffMemberId, r.PickupMemberId)); + + // โ”€โ”€ Per-date exceptions in range. + var exceptions = await _db.SchoolDayExceptions + .AsNoTracking() + .Where(e => e.FamilyId == familyId && e.Date >= request.From && e.Date <= request.To) + .Select(e => new SchoolDayExceptionDto( + e.Id, + e.FamilyMemberId, + e.Date, + e.IsCancelled, + e.DropoffMemberId, + e.PickupMemberId, + e.MorningTime, + e.AfternoonTime, + e.Notes)) + .ToListAsync(cancellationToken); + + var exceptionLookup = exceptions + .ToDictionary(e => (e.MemberId, e.Date)); + + // โ”€โ”€ Holidays that intersect the range. + var holidays = await _db.SchoolHolidays + .AsNoTracking() + .Where(h => h.FamilyId == familyId && h.StartDate <= request.To && h.EndDate >= request.From) + .OrderBy(h => h.StartDate) + .Select(h => new SchoolHolidayDto(h.Id, h.StartDate, h.EndDate, h.Label)) + .ToListAsync(cancellationToken); + + // Per-kid profile bits we need for resolution: transport mode + the + // default morning / afternoon times that exception overrides fall + // back to. Defaults to None / null times when the kid has no profile. + var profileByKid = await _db.SchoolProfiles + .AsNoTracking() + .Where(p => p.FamilyMember!.FamilyId == familyId) + .Select(p => new { p.FamilyMemberId, p.TransportMode, p.MorningTime, p.AfternoonTime }) + .ToDictionaryAsync( + p => p.FamilyMemberId, + p => (p.TransportMode, p.MorningTime, p.AfternoonTime), + cancellationToken); + + Domain.School.TransportMode TransportFor(Guid kidId) + => profileByKid.TryGetValue(kidId, out var p) ? p.TransportMode : Domain.School.TransportMode.None; + (TimeOnly? Morning, TimeOnly? Afternoon) DefaultTimesFor(Guid kidId) + => profileByKid.TryGetValue(kidId, out var p) ? (p.MorningTime, p.AfternoonTime) : (null, null); + + // โ”€โ”€ Resolution loop: for every (kid that has any schedule row OR any + // exception in range, day in range) emit a resolved row. + var kidIds = scheduleRows.Select(r => r.FamilyMemberId) + .Concat(exceptions.Select(e => e.MemberId)) + .Distinct() + .ToList(); + + var resolved = new List(); + for (var date = request.From; date <= request.To; date = date.AddDays(1)) + { + var holiday = holidays.FirstOrDefault(h => h.StartDate <= date && h.EndDate >= date); + foreach (var kidId in kidIds) + { + var hasException = exceptionLookup.TryGetValue((kidId, date), out var ex); + var hasSchedule = scheduleLookup.TryGetValue((kidId, date.DayOfWeek), out var fromSchedule); + + // Skip cells that have nothing to say (no schedule, no override, no holiday match). + if (!hasException && !hasSchedule && holiday is null) continue; + + var mode = TransportFor(kidId); + var defaults = DefaultTimesFor(kidId); + if (holiday is not null) + { + resolved.Add(new ResolvedSchoolDayDto( + kidId, date, mode, + null, null, null, null, + IsCancelled: true, holiday.Label, ex?.Notes)); + continue; + } + if (hasException) + { + // Override semantics: if a slot is null in the exception we still + // fall back to the schedule for that slot โ€” that lets you override + // just the pickup of a day without forcing the user to re-enter the + // morning drop-off. Same for times: exception time wins, otherwise + // we ship the profile defaults. + var exDropoff = ex!.DropoffMemberId ?? (hasSchedule ? fromSchedule.DropoffMemberId : null); + var exPickup = ex.PickupMemberId ?? (hasSchedule ? fromSchedule.PickupMemberId : null); + var exMorning = ex.MorningTime ?? defaults.Morning; + var exAfternoon = ex.AfternoonTime ?? defaults.Afternoon; + resolved.Add(new ResolvedSchoolDayDto( + kidId, date, mode, + ex.IsCancelled ? null : exDropoff, + ex.IsCancelled ? null : exPickup, + ex.IsCancelled ? null : exMorning, + ex.IsCancelled ? null : exAfternoon, + ex.IsCancelled, null, ex.Notes)); + continue; + } + resolved.Add(new ResolvedSchoolDayDto( + kidId, date, mode, + fromSchedule.DropoffMemberId, + fromSchedule.PickupMemberId, + defaults.Morning, + defaults.Afternoon, + IsCancelled: false, null, null)); + } + } + + // โ”€โ”€ Extracurriculars active in the family + their exceptions in range. + var extracurriculars = await _db.Extracurriculars + .AsNoTracking() + .Where(e => e.FamilyId == familyId && !e.IsArchived + && e.StartDate <= request.To + && (e.EndDate == null || e.EndDate >= request.From)) + .ToListAsync(cancellationToken); + + var extracurricularDtos = extracurriculars + .Select(e => new ExtracurricularDto( + e.Id, e.FamilyMemberId, e.Name, e.Location, e.ContactPhone, + e.WeeklyDays, e.StartTime, e.EndTime, e.StartDate, e.EndDate, + e.DefaultDropoffMemberId, e.DefaultPickupMemberId, e.Notes, e.IsArchived)) + .ToList(); + + var extracurricularIds = extracurriculars.Select(e => e.Id).ToList(); + var extraExceptions = extracurricularIds.Count == 0 + ? new List() + : await _db.ExtracurricularExceptions + .AsNoTracking() + .Where(x => extracurricularIds.Contains(x.ExtracurricularId) + && x.Date >= request.From && x.Date <= request.To) + .Select(x => new ExtracurricularExceptionDto( + x.Id, x.ExtracurricularId, x.Date, x.IsCancelled, + x.DropoffMemberId, x.PickupMemberId, x.Notes)) + .ToListAsync(cancellationToken); + + var extraExceptionLookup = extraExceptions + .ToDictionary(x => (x.ExtracurricularId, x.Date)); + + // โ”€โ”€ Resolution: each extracurricular yields one row per scheduled + // weekday inside the requested range, then merges exception/holiday. + var resolvedExtra = new List(); + foreach (var activity in extracurriculars) + { + var floor = activity.StartDate > request.From ? activity.StartDate : request.From; + var ceiling = activity.EndDate is { } end && end < request.To ? end : request.To; + for (var date = floor; date <= ceiling; date = date.AddDays(1)) + { + if ((activity.WeeklyDays & ToMask(date.DayOfWeek)) == DayOfWeekMask.None) continue; + + var holiday = holidays.FirstOrDefault(h => h.StartDate <= date && h.EndDate >= date); + var hasException = extraExceptionLookup.TryGetValue((activity.Id, date), out var ex); + + if (holiday is not null) + { + resolvedExtra.Add(new ResolvedExtracurricularDto( + activity.Id, activity.FamilyMemberId, date, + activity.StartTime, activity.EndTime, activity.Name, + activity.Location, activity.ContactPhone, + null, null, + IsCancelled: true, holiday.Label, ex?.Notes)); + continue; + } + if (hasException && ex!.IsCancelled) + { + resolvedExtra.Add(new ResolvedExtracurricularDto( + activity.Id, activity.FamilyMemberId, date, + activity.StartTime, activity.EndTime, activity.Name, + activity.Location, activity.ContactPhone, + null, null, + IsCancelled: true, null, ex.Notes)); + continue; + } + var dropoff = (hasException ? ex!.DropoffMemberId : null) ?? activity.DefaultDropoffMemberId; + var pickup = (hasException ? ex!.PickupMemberId : null) ?? activity.DefaultPickupMemberId; + resolvedExtra.Add(new ResolvedExtracurricularDto( + activity.Id, activity.FamilyMemberId, date, + activity.StartTime, activity.EndTime, activity.Name, + activity.Location, activity.ContactPhone, + dropoff, pickup, + IsCancelled: false, null, hasException ? ex!.Notes : null)); + } + } + + return new SchoolOverviewDto( + request.From, request.To, scheduleByKid, exceptions, holidays, resolved, + extracurricularDtos, extraExceptions, resolvedExtra); + } + + private static DayOfWeekMask ToMask(DayOfWeek dow) => dow switch + { + DayOfWeek.Monday => DayOfWeekMask.Monday, + DayOfWeek.Tuesday => DayOfWeekMask.Tuesday, + DayOfWeek.Wednesday => DayOfWeekMask.Wednesday, + DayOfWeek.Thursday => DayOfWeekMask.Thursday, + DayOfWeek.Friday => DayOfWeekMask.Friday, + DayOfWeek.Saturday => DayOfWeekMask.Saturday, + DayOfWeek.Sunday => DayOfWeekMask.Sunday, + _ => DayOfWeekMask.None, + }; + } +} diff --git a/src/FamilyNido.Api/Features/School/GetSchoolProfile.cs b/src/FamilyNido.Api/Features/School/GetSchoolProfile.cs new file mode 100644 index 0000000..890ee66 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/GetSchoolProfile.cs @@ -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.School; + +/// +/// Slice for GET /api/school/members/{memberId}/profile. Returns the +/// stored card or null when the kid has no profile yet. +/// +public static class GetSchoolProfile +{ + /// Query carrying the member id. + public sealed record Query(Guid MemberId) : IRequest>; + + /// Validates family scope and returns the profile DTO (nullable). + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 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 profile = await _db.SchoolProfiles + .AsNoTracking() + .Where(p => p.FamilyMemberId == request.MemberId) + .Select(p => new SchoolProfileDto( + p.SchoolName, + p.Grade, + p.Tutor, + p.TransportMode, + p.MorningTime, + p.AfternoonTime, + p.Notes)) + .FirstOrDefaultAsync(cancellationToken); + + return profile; + } + } +} diff --git a/src/FamilyNido.Api/Features/School/ListExtracurriculars.cs b/src/FamilyNido.Api/Features/School/ListExtracurriculars.cs new file mode 100644 index 0000000..3f8115f --- /dev/null +++ b/src/FamilyNido.Api/Features/School/ListExtracurriculars.cs @@ -0,0 +1,55 @@ +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.School; + +/// +/// Slice for GET /api/school/extracurriculars?includeArchived=โ€ฆ. Lists +/// the family's after-school activities, optionally including archived rows +/// for the historical view. +/// +public static class ListExtracurriculars +{ + /// Query carrying the include-archived flag. + public sealed record Query(bool IncludeArchived) : IRequest>>; + + /// Reads the rows from the caller's family. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task>> 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 rows = await _db.Extracurriculars + .AsNoTracking() + .Where(e => e.FamilyId == current.Family.Id && (request.IncludeArchived || !e.IsArchived)) + .OrderBy(e => e.IsArchived).ThenBy(e => e.Name) + .ToListAsync(cancellationToken); + + IReadOnlyList dtos = rows.Select(e => new ExtracurricularDto( + e.Id, e.FamilyMemberId, e.Name, e.Location, e.ContactPhone, + e.WeeklyDays, e.StartTime, e.EndTime, e.StartDate, e.EndDate, + e.DefaultDropoffMemberId, e.DefaultPickupMemberId, e.Notes, e.IsArchived)).ToList(); + return Result>.Success(dtos); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/RemoveExtracurricularException.cs b/src/FamilyNido.Api/Features/School/RemoveExtracurricularException.cs new file mode 100644 index 0000000..426018b --- /dev/null +++ b/src/FamilyNido.Api/Features/School/RemoveExtracurricularException.cs @@ -0,0 +1,58 @@ +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.School; + +/// Slice for DELETE /api/school/extracurriculars/{id}/exceptions/{date}. +public static class RemoveExtracurricularException +{ + /// Command identifying the override row. + public sealed record Command(Guid ExtracurricularId, DateOnly Date) : IRequest>; + + /// Empty success payload. + public sealed record Unit; + + /// Removes the row when present; idempotent when absent. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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.ExtracurricularExceptions + .Include(x => x.Extracurricular) + .FirstOrDefaultAsync(x => x.ExtracurricularId == request.ExtracurricularId && x.Date == request.Date, cancellationToken); + + if (entry is not null) + { + if (entry.Extracurricular!.FamilyId != current.Family.Id) + { + return ApplicationError.NotFound("school.extracurricular.exception_not_found", "Exception not found."); + } + _db.ExtracurricularExceptions.Remove(entry); + await _db.SaveChangesAsync(cancellationToken); + } + + return new Unit(); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/RemoveSchoolDayException.cs b/src/FamilyNido.Api/Features/School/RemoveSchoolDayException.cs new file mode 100644 index 0000000..2884aea --- /dev/null +++ b/src/FamilyNido.Api/Features/School/RemoveSchoolDayException.cs @@ -0,0 +1,60 @@ +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.School; + +/// +/// Slice for DELETE /api/school/day-schedule/exceptions/{memberId}/{date}. +/// Removes the override and falls back to the weekly schedule. +/// +public static class RemoveSchoolDayException +{ + /// Command identifying the row to delete. + public sealed record Command(Guid MemberId, DateOnly Date) : IRequest>; + + /// Empty success payload. + public sealed record Unit; + + /// Removes the row when present; idempotent when absent. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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.SchoolDayExceptions + .FirstOrDefaultAsync(e => e.FamilyMemberId == request.MemberId && e.Date == request.Date, cancellationToken); + + if (entry is not null) + { + if (entry.FamilyId != current.Family.Id) + { + return ApplicationError.NotFound("school.day_schedule.exception_not_found", "Exception not found."); + } + _db.SchoolDayExceptions.Remove(entry); + await _db.SaveChangesAsync(cancellationToken); + } + + return new Unit(); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/ReplaceSchoolDaySchedule.cs b/src/FamilyNido.Api/Features/School/ReplaceSchoolDaySchedule.cs new file mode 100644 index 0000000..98a40e6 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/ReplaceSchoolDaySchedule.cs @@ -0,0 +1,127 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.School; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.School; + +/// +/// Slice for PUT /api/school/members/{memberId}/day-schedule. Replaces +/// the entire weekly pattern of one kid in a single shot. Sending an empty +/// list clears the schedule; sending rows with both caretakers null is +/// rejected โ€” at least one slot must be set. +/// +public static class ReplaceSchoolDaySchedule +{ + /// Command carrying the kid id and the new weekly slots. + public sealed record Command( + Guid MemberId, + IReadOnlyList Slots) : IRequest>; + + /// Validation: each slot has at least one caretaker; weekdays unique. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Slots).NotNull(); + RuleForEach(x => x.Slots).ChildRules(slot => + { + slot.RuleFor(s => s) + .Must(s => s.DropoffMemberId is not null || s.PickupMemberId is not null) + .WithMessage("Each slot must set at least one caretaker (drop-off or pick-up)."); + }); + RuleFor(x => x.Slots) + .Must(slots => slots.Select(s => s.DayOfWeek).Distinct().Count() == slots.Count) + .WithMessage("Duplicate weekday in the schedule."); + } + } + + /// Diffs the persisted schedule against the request and applies the delta. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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."); + } + + // Validate the kid + every caretaker referenced โ€” all must belong to this family. + var memberIds = new HashSet { request.MemberId }; + foreach (var slot in request.Slots) + { + if (slot.DropoffMemberId is { } d) memberIds.Add(d); + if (slot.PickupMemberId is { } p) memberIds.Add(p); + } + + var familyMembers = await _db.FamilyMembers + .Where(m => memberIds.Contains(m.Id) && m.FamilyId == current.Family.Id) + .Select(m => m.Id) + .ToListAsync(cancellationToken); + + if (familyMembers.Count != memberIds.Count) + { + return ApplicationError.Validation( + "school.day_schedule.unknown_member", + "One or more referenced members are not part of this family."); + } + + var existing = await _db.SchoolDaySchedules + .Where(s => s.FamilyMemberId == request.MemberId) + .ToListAsync(cancellationToken); + + var requested = request.Slots.ToDictionary(s => s.DayOfWeek); + + // Update overlapping rows, drop the ones that disappeared. + foreach (var row in existing) + { + if (requested.TryGetValue(row.DayOfWeek, out var slot)) + { + row.DropoffMemberId = slot.DropoffMemberId; + row.PickupMemberId = slot.PickupMemberId; + } + else + { + _db.SchoolDaySchedules.Remove(row); + } + } + + // Add brand-new weekday rows. + var existingDays = existing.Select(r => r.DayOfWeek).ToHashSet(); + foreach (var slot in request.Slots) + { + if (existingDays.Contains(slot.DayOfWeek)) continue; + _db.SchoolDaySchedules.Add(new SchoolDaySchedule + { + FamilyMemberId = request.MemberId, + DayOfWeek = slot.DayOfWeek, + DropoffMemberId = slot.DropoffMemberId, + PickupMemberId = slot.PickupMemberId, + }); + } + + await _db.SaveChangesAsync(cancellationToken); + + return new KidScheduleDto( + request.MemberId, + request.Slots.OrderBy(s => s.DayOfWeek).ToList()); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/SchoolDtos.cs b/src/FamilyNido.Api/Features/School/SchoolDtos.cs new file mode 100644 index 0000000..e328e49 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/SchoolDtos.cs @@ -0,0 +1,104 @@ +using FamilyNido.Domain.School; + +namespace FamilyNido.Api.Features.School; + +/// Static school card for one member. +/// Name of the school / daycare / centre. +/// Course / level. +/// Tutor name. +/// How the kid commutes to / from this centre. +/// Typical local time the kid arrives at the centre (HH:mm). +/// Typical local time the kid is picked up (HH:mm). +/// Free-form notes. +public sealed record SchoolProfileDto( + string? SchoolName, + string? Grade, + string? Tutor, + TransportMode TransportMode, + TimeOnly? MorningTime, + TimeOnly? AfternoonTime, + string? Notes); + +/// Single weekly slot of the school-day schedule. +/// Weekday (0 = Sunday โ€ฆ 6 = Saturday, .NET ordinals). +/// Caretaker that takes the kid in the morning, when applicable. +/// Caretaker that picks the kid up in the afternoon, when applicable. +public sealed record SchoolDayScheduleSlotDto( + DayOfWeek DayOfWeek, + Guid? DropoffMemberId, + Guid? PickupMemberId); + +/// Per-date override of the school-day schedule. +/// Stable id. +/// Kid the override applies to. +/// Date of the override. +/// True when there's no commute that day. +/// Caretaker that takes the kid that day, or null. +/// Caretaker that picks the kid up that day, or null. +/// Override morning entry time for this date, or null when unchanged. +/// Override afternoon exit time for this date, or null when unchanged. +/// Optional context note. +public sealed record SchoolDayExceptionDto( + Guid Id, + Guid MemberId, + DateOnly Date, + bool IsCancelled, + Guid? DropoffMemberId, + Guid? PickupMemberId, + TimeOnly? MorningTime, + TimeOnly? AfternoonTime, + string? Notes); + +/// Family-wide school holiday entry. +public sealed record SchoolHolidayDto( + Guid Id, + DateOnly StartDate, + DateOnly EndDate, + string Label); + +/// Resolved school day for a single (kid, date) cell shown in the weekly grid. +/// Kid id. +/// Date. +/// Kid's transport mode โ€” drives the dashboard / widget icon. +/// Resolved drop-off caretaker (override โ†’ schedule โ†’ null). +/// Resolved pick-up caretaker (override โ†’ schedule โ†’ null). +/// Effective entry time (exception override โ†’ profile default โ†’ null). +/// Effective exit time (exception override โ†’ profile default โ†’ null). +/// True when there's no commute that day (override or holiday). +/// When cancelled by a holiday, the holiday label; otherwise null. +/// Override note when applicable. +public sealed record ResolvedSchoolDayDto( + Guid MemberId, + DateOnly Date, + TransportMode TransportMode, + Guid? DropoffMemberId, + Guid? PickupMemberId, + TimeOnly? MorningTime, + TimeOnly? AfternoonTime, + bool IsCancelled, + string? HolidayLabel, + string? Notes); + +/// Top-level overview returned by GET /api/school/overview. +/// Inclusive start of the requested range. +/// Inclusive end of the requested range. +/// Persisted weekly school-day pattern (one row per (kid, weekday)). +/// School-day exceptions whose date falls in the range. +/// Holiday rows that intersect the range. +/// Day-by-day resolved school-day per kid (cancelled rows included). +/// Active extracurriculars that overlap the range. +/// Per-date overrides whose date falls in the range. +/// Day-by-day resolved sessions in the range. +public sealed record SchoolOverviewDto( + DateOnly From, + DateOnly To, + IReadOnlyList Schedule, + IReadOnlyList DayExceptions, + IReadOnlyList Holidays, + IReadOnlyList ResolvedDays, + IReadOnlyList Extracurriculars, + IReadOnlyList ExtracurricularExceptions, + IReadOnlyList ResolvedExtracurriculars); + +/// Compact view of the weekly schedule grouped by kid. +public sealed record KidScheduleDto(Guid MemberId, IReadOnlyList Slots); diff --git a/src/FamilyNido.Api/Features/School/SchoolEndpoints.cs b/src/FamilyNido.Api/Features/School/SchoolEndpoints.cs new file mode 100644 index 0000000..b1d2b31 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/SchoolEndpoints.cs @@ -0,0 +1,296 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Domain.School; +using FluentValidation; + +namespace FamilyNido.Api.Features.School; + +/// +/// HTTP surface of the Cole module. Like the Salud module, every route is +/// gated to so guests never see school logistics. +/// +public static class SchoolEndpoints +{ + /// Registers the /api/school/* routes on the given builder. + public static IEndpointRouteBuilder MapSchoolEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/school").WithTags("School"); + + group.MapGet("/overview", GetOverviewAsync).RequireAuthorization(Policies.Adult); + + group.MapGet("/members/{memberId:guid}/profile", GetProfileAsync).RequireAuthorization(Policies.Adult); + group.MapPut("/members/{memberId:guid}/profile", UpsertProfileAsync).RequireAuthorization(Policies.Adult); + + group.MapPut("/members/{memberId:guid}/day-schedule", ReplaceDayScheduleAsync).RequireAuthorization(Policies.Adult); + + group.MapPut("/day-schedule/exceptions/{memberId:guid}/{date}", SetDayExceptionAsync).RequireAuthorization(Policies.Adult); + group.MapDelete("/day-schedule/exceptions/{memberId:guid}/{date}", RemoveDayExceptionAsync).RequireAuthorization(Policies.Adult); + + group.MapPost("/holidays", AddHolidayAsync).RequireAuthorization(Policies.Adult); + group.MapPut("/holidays/{id:guid}", UpdateHolidayAsync).RequireAuthorization(Policies.Adult); + group.MapDelete("/holidays/{id:guid}", DeleteHolidayAsync).RequireAuthorization(Policies.Adult); + + group.MapGet("/extracurriculars", ListExtracurricularsAsync).RequireAuthorization(Policies.Adult); + group.MapPost("/extracurriculars", AddExtracurricularAsync).RequireAuthorization(Policies.Adult); + group.MapPut("/extracurriculars/{id:guid}", UpdateExtracurricularAsync).RequireAuthorization(Policies.Adult); + group.MapDelete("/extracurriculars/{id:guid}", DeleteExtracurricularAsync).RequireAuthorization(Policies.Adult); + group.MapPatch("/extracurriculars/{id:guid}/archive", ArchiveExtracurricularAsync).RequireAuthorization(Policies.Adult); + group.MapPatch("/extracurriculars/{id:guid}/restore", RestoreExtracurricularAsync).RequireAuthorization(Policies.Adult); + group.MapPut("/extracurriculars/{id:guid}/exceptions/{date}", SetExtracurricularExceptionAsync).RequireAuthorization(Policies.Adult); + group.MapDelete("/extracurriculars/{id:guid}/exceptions/{date}", RemoveExtracurricularExceptionAsync).RequireAuthorization(Policies.Adult); + + return app; + } + + private static async Task GetOverviewAsync( + DateOnly from, + DateOnly to, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new GetSchoolOverview.Query(from, to), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task GetProfileAsync(Guid memberId, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new GetSchoolProfile.Query(memberId), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task UpsertProfileAsync( + Guid memberId, + SchoolProfileBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new UpsertSchoolProfile.Command( + memberId, + body.SchoolName, + body.Grade, + body.Tutor, + body.TransportMode, + body.MorningTime, + body.AfternoonTime, + 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 ReplaceDayScheduleAsync( + Guid memberId, + ReplaceScheduleBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new ReplaceSchoolDaySchedule.Command(memberId, body.Slots); + 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 SetDayExceptionAsync( + Guid memberId, + DateOnly date, + DayExceptionBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new SetSchoolDayException.Command( + memberId, + date, + body.IsCancelled, + body.DropoffMemberId, + body.PickupMemberId, + body.MorningTime, + body.AfternoonTime, + 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 RemoveDayExceptionAsync( + Guid memberId, + DateOnly date, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new RemoveSchoolDayException.Command(memberId, date), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } + + private static async Task AddHolidayAsync( + HolidayBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new AddSchoolHoliday.Command(body.StartDate, body.EndDate, body.Label); + 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 UpdateHolidayAsync( + Guid id, + HolidayBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new UpdateSchoolHoliday.Command(id, body.StartDate, body.EndDate, body.Label); + 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 DeleteHolidayAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new DeleteSchoolHoliday.Command(id), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } + + /// Body for the school-profile upsert. + public sealed record SchoolProfileBody( + string? SchoolName, + string? Grade, + string? Tutor, + TransportMode TransportMode, + TimeOnly? MorningTime, + TimeOnly? AfternoonTime, + string? Notes); + + /// Body for the school-day schedule replace. + public sealed record ReplaceScheduleBody(IReadOnlyList Slots); + + /// Body for the school-day exception (drop-off + pick-up overrides or cancellation). + public sealed record DayExceptionBody( + bool IsCancelled, + Guid? DropoffMemberId, + Guid? PickupMemberId, + TimeOnly? MorningTime, + TimeOnly? AfternoonTime, + string? Notes); + + /// Body shared by add/update holiday. + public sealed record HolidayBody(DateOnly StartDate, DateOnly EndDate, string Label); + + private static async Task ListExtracurricularsAsync( + bool? includeArchived, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new ListExtracurriculars.Query(includeArchived ?? false), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task AddExtracurricularAsync( + ExtracurricularBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new AddExtracurricular.Command( + body.MemberId, body.Name, body.Location, body.ContactPhone, + body.WeeklyDays, body.StartTime, body.EndTime, body.StartDate, body.EndDate, + body.DefaultDropoffMemberId, body.DefaultPickupMemberId, 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 UpdateExtracurricularAsync( + Guid id, + ExtracurricularBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new UpdateExtracurricular.Command( + id, body.MemberId, body.Name, body.Location, body.ContactPhone, + body.WeeklyDays, body.StartTime, body.EndTime, body.StartDate, body.EndDate, + body.DefaultDropoffMemberId, body.DefaultPickupMemberId, 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 ArchiveExtracurricularAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new ArchiveExtracurricular.Command(id, IsArchived: true), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task RestoreExtracurricularAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new ArchiveExtracurricular.Command(id, IsArchived: false), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task DeleteExtracurricularAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new DeleteExtracurricular.Command(id), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } + + private static async Task SetExtracurricularExceptionAsync( + Guid id, + DateOnly date, + ExtracurricularExceptionBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new SetExtracurricularException.Command( + id, date, body.IsCancelled, body.DropoffMemberId, body.PickupMemberId, 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 RemoveExtracurricularExceptionAsync( + Guid id, + DateOnly date, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new RemoveExtracurricularException.Command(id, date), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } + + /// Body shared by add/update extracurricular. + public sealed record ExtracurricularBody( + Guid MemberId, + string Name, + string? Location, + string? ContactPhone, + Domain.HouseholdTasks.DayOfWeekMask WeeklyDays, + TimeOnly StartTime, + TimeOnly EndTime, + DateOnly StartDate, + DateOnly? EndDate, + Guid? DefaultDropoffMemberId, + Guid? DefaultPickupMemberId, + string? Notes); + + /// Body shared by add/update extracurricular exception. + public sealed record ExtracurricularExceptionBody( + bool IsCancelled, + Guid? DropoffMemberId, + Guid? PickupMemberId, + string? Notes); +} diff --git a/src/FamilyNido.Api/Features/School/SetExtracurricularException.cs b/src/FamilyNido.Api/Features/School/SetExtracurricularException.cs new file mode 100644 index 0000000..cda7cb4 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/SetExtracurricularException.cs @@ -0,0 +1,115 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.School; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.School; + +/// +/// Slice for PUT /api/school/extracurriculars/{id}/exceptions/{date}. +/// Upserts a per-session override or cancellation. Mirror of +/// for the activities side. +/// +public static class SetExtracurricularException +{ + /// Command carrying the (activity, date) tuple and the new state. + public sealed record Command( + Guid ExtracurricularId, + DateOnly Date, + bool IsCancelled, + Guid? DropoffMemberId, + Guid? PickupMemberId, + string? Notes) : IRequest>; + + /// Validation: cancellation OR an actual change must be present. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Notes).MaximumLength(500); + RuleFor(x => x) + .Must(c => c.IsCancelled || c.DropoffMemberId is not null || c.PickupMemberId is not null || !string.IsNullOrWhiteSpace(c.Notes)) + .WithMessage("Provide at least one change (cancel, override drop-off / pick-up, or a note)."); + } + } + + /// Inserts or updates the exception row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 activity = await _db.Extracurriculars.FirstOrDefaultAsync(e => e.Id == request.ExtracurricularId, cancellationToken); + if (activity is null || activity.FamilyId != current.Family.Id) + { + return ApplicationError.NotFound("school.extracurricular.not_found", $"Activity {request.ExtracurricularId} not found."); + } + + var ids = new List(); + if (request.DropoffMemberId is { } d) ids.Add(d); + if (request.PickupMemberId is { } p) ids.Add(p); + if (ids.Count > 0) + { + var found = await _db.FamilyMembers + .Where(m => ids.Contains(m.Id) && m.FamilyId == current.Family.Id) + .CountAsync(cancellationToken); + if (found != ids.Distinct().Count()) + { + return ApplicationError.Validation( + "school.extracurricular.unknown_member", + "Caretaker referenced is not part of this family."); + } + } + + var entry = await _db.ExtracurricularExceptions + .FirstOrDefaultAsync(x => x.ExtracurricularId == request.ExtracurricularId && x.Date == request.Date, cancellationToken); + + if (entry is null) + { + entry = new ExtracurricularException + { + ExtracurricularId = request.ExtracurricularId, + Date = request.Date, + }; + _db.ExtracurricularExceptions.Add(entry); + } + + entry.IsCancelled = request.IsCancelled; + entry.DropoffMemberId = request.IsCancelled ? null : request.DropoffMemberId; + entry.PickupMemberId = request.IsCancelled ? null : request.PickupMemberId; + entry.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + + await _db.SaveChangesAsync(cancellationToken); + + return new ExtracurricularExceptionDto( + entry.Id, + entry.ExtracurricularId, + entry.Date, + entry.IsCancelled, + entry.DropoffMemberId, + entry.PickupMemberId, + entry.Notes); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/SetSchoolDayException.cs b/src/FamilyNido.Api/Features/School/SetSchoolDayException.cs new file mode 100644 index 0000000..607ad57 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/SetSchoolDayException.cs @@ -0,0 +1,129 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.School; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.School; + +/// +/// Slice for PUT /api/school/day-schedule/exceptions/{memberId}/{date}. +/// Upserts a per-date override of the school commute. Either flag the day as +/// cancelled or reassign drop-off / pick-up โ€” both fields can be set +/// simultaneously when both legs of the day change. +/// +public static class SetSchoolDayException +{ + /// Command carrying the (kid, date) tuple and the new state. + public sealed record Command( + Guid MemberId, + DateOnly Date, + bool IsCancelled, + Guid? DropoffMemberId, + Guid? PickupMemberId, + TimeOnly? MorningTime, + TimeOnly? AfternoonTime, + string? Notes) : IRequest>; + + /// Validation: must either cancel or set at least one caretaker change. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Notes).MaximumLength(500); + RuleFor(x => x) + .Must(c => c.IsCancelled + || c.DropoffMemberId is not null + || c.PickupMemberId is not null + || c.MorningTime is not null + || c.AfternoonTime is not null + || !string.IsNullOrWhiteSpace(c.Notes)) + .WithMessage("Provide at least one change (cancel, override drop-off / pick-up, custom times, or a note)."); + } + } + + /// Inserts or updates the row. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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."); + } + + // Caretaker references must belong to the same family. + var caretakerIds = new List(); + if (request.DropoffMemberId is { } d) caretakerIds.Add(d); + if (request.PickupMemberId is { } p) caretakerIds.Add(p); + if (caretakerIds.Count > 0) + { + var found = await _db.FamilyMembers + .CountAsync(m => caretakerIds.Contains(m.Id) && m.FamilyId == current.Family.Id, cancellationToken); + if (found != caretakerIds.Distinct().Count()) + { + return ApplicationError.Validation( + "school.day_schedule.unknown_caretaker", + "Caretaker is not part of this family."); + } + } + + var entry = await _db.SchoolDayExceptions + .FirstOrDefaultAsync(e => e.FamilyMemberId == request.MemberId && e.Date == request.Date, cancellationToken); + + if (entry is null) + { + entry = new SchoolDayException + { + FamilyId = current.Family.Id, + FamilyMemberId = request.MemberId, + Date = request.Date, + }; + _db.SchoolDayExceptions.Add(entry); + } + + entry.IsCancelled = request.IsCancelled; + entry.DropoffMemberId = request.IsCancelled ? null : request.DropoffMemberId; + entry.PickupMemberId = request.IsCancelled ? null : request.PickupMemberId; + entry.MorningTime = request.IsCancelled ? null : request.MorningTime; + entry.AfternoonTime = request.IsCancelled ? null : request.AfternoonTime; + entry.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + + await _db.SaveChangesAsync(cancellationToken); + + return new SchoolDayExceptionDto( + entry.Id, + entry.FamilyMemberId, + entry.Date, + entry.IsCancelled, + entry.DropoffMemberId, + entry.PickupMemberId, + entry.MorningTime, + entry.AfternoonTime, + entry.Notes); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/UpdateExtracurricular.cs b/src/FamilyNido.Api/Features/School/UpdateExtracurricular.cs new file mode 100644 index 0000000..8941e68 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/UpdateExtracurricular.cs @@ -0,0 +1,113 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.HouseholdTasks; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.School; + +/// Slice for PUT /api/school/extracurriculars/{id}. +public static class UpdateExtracurricular +{ + /// Command replacing the editable fields of an activity row. + public sealed record Command( + Guid Id, + Guid MemberId, + string Name, + string? Location, + string? ContactPhone, + DayOfWeekMask WeeklyDays, + TimeOnly StartTime, + TimeOnly EndTime, + DateOnly StartDate, + DateOnly? EndDate, + Guid? DefaultDropoffMemberId, + Guid? DefaultPickupMemberId, + string? Notes) : IRequest>; + + /// Input validation โ€” mirrors . + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Name).NotEmpty().MaximumLength(120); + RuleFor(x => x.Location).MaximumLength(160); + RuleFor(x => x.ContactPhone).MaximumLength(40); + RuleFor(x => x.Notes).MaximumLength(2000); + RuleFor(x => x.WeeklyDays).NotEqual(DayOfWeekMask.None); + RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime); + RuleFor(x => x.EndDate) + .GreaterThanOrEqualTo(x => x.StartDate) + .When(x => x.EndDate is not null); + } + } + + /// Updates after verifying family scope. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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.Extracurriculars.FirstOrDefaultAsync(e => e.Id == request.Id, cancellationToken); + if (entry is null || entry.FamilyId != current.Family.Id) + { + return ApplicationError.NotFound("school.extracurricular.not_found", $"Activity {request.Id} not found."); + } + + var ids = new HashSet { request.MemberId }; + if (request.DefaultDropoffMemberId is { } d) ids.Add(d); + if (request.DefaultPickupMemberId is { } p) ids.Add(p); + + var found = await _db.FamilyMembers + .Where(m => ids.Contains(m.Id) && m.FamilyId == current.Family.Id) + .Select(m => m.Id) + .ToListAsync(cancellationToken); + if (found.Count != ids.Count) + { + return ApplicationError.Validation( + "school.extracurricular.unknown_member", + "One or more referenced members are not part of this family."); + } + + entry.FamilyMemberId = request.MemberId; + entry.Name = request.Name.Trim(); + entry.Location = Trim(request.Location); + entry.ContactPhone = Trim(request.ContactPhone); + entry.WeeklyDays = request.WeeklyDays; + entry.StartTime = request.StartTime; + entry.EndTime = request.EndTime; + entry.StartDate = request.StartDate; + entry.EndDate = request.EndDate; + entry.DefaultDropoffMemberId = request.DefaultDropoffMemberId; + entry.DefaultPickupMemberId = request.DefaultPickupMemberId; + entry.Notes = Trim(request.Notes); + + await _db.SaveChangesAsync(cancellationToken); + return AddExtracurricular.Handler.ToDto(entry); + } + + private static string? Trim(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/src/FamilyNido.Api/Features/School/UpdateSchoolHoliday.cs b/src/FamilyNido.Api/Features/School/UpdateSchoolHoliday.cs new file mode 100644 index 0000000..c2d2b24 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/UpdateSchoolHoliday.cs @@ -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 FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.School; + +/// Slice for PUT /api/school/holidays/{id}. +public static class UpdateSchoolHoliday +{ + /// Command replacing the editable fields of a holiday row. + public sealed record Command(Guid Id, DateOnly StartDate, DateOnly EndDate, string Label) + : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Label).NotEmpty().MaximumLength(120); + RuleFor(x => x.EndDate).GreaterThanOrEqualTo(x => x.StartDate); + } + } + + /// Updates after verifying family scope. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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.SchoolHolidays.FirstOrDefaultAsync(h => h.Id == request.Id, cancellationToken); + if (entry is null || entry.FamilyId != current.Family.Id) + { + return ApplicationError.NotFound("school.holiday.not_found", $"Holiday {request.Id} not found."); + } + + entry.StartDate = request.StartDate; + entry.EndDate = request.EndDate; + entry.Label = request.Label.Trim(); + + await _db.SaveChangesAsync(cancellationToken); + + return new SchoolHolidayDto(entry.Id, entry.StartDate, entry.EndDate, entry.Label); + } + } +} diff --git a/src/FamilyNido.Api/Features/School/UpsertSchoolProfile.cs b/src/FamilyNido.Api/Features/School/UpsertSchoolProfile.cs new file mode 100644 index 0000000..ff983f7 --- /dev/null +++ b/src/FamilyNido.Api/Features/School/UpsertSchoolProfile.cs @@ -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.Domain.School; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.School; + +/// +/// Slice for PUT /api/school/members/{memberId}/profile. Lazily creates +/// the row on first save, then updates in place. All free-text fields are +/// nullable so a minimal "ficha" is allowed. +/// +public static class UpsertSchoolProfile +{ + /// Replaces the kid's school profile in full. + public sealed record Command( + Guid MemberId, + string? SchoolName, + string? Grade, + string? Tutor, + TransportMode TransportMode, + TimeOnly? MorningTime, + TimeOnly? AfternoonTime, + string? Notes) : IRequest>; + + /// Validation: cap each free-text field to the persisted length. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.SchoolName).MaximumLength(120); + RuleFor(x => x.Grade).MaximumLength(60); + RuleFor(x => x.Tutor).MaximumLength(120); + RuleFor(x => x.Notes).MaximumLength(2000); + RuleFor(x => x.TransportMode).IsInEnum(); + } + } + + /// Performs the upsert with family-scope validation. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 profile = await _db.SchoolProfiles + .FirstOrDefaultAsync(p => p.FamilyMemberId == request.MemberId, cancellationToken); + if (profile is null) + { + profile = new SchoolProfile { FamilyMemberId = request.MemberId }; + _db.SchoolProfiles.Add(profile); + } + + profile.SchoolName = Trim(request.SchoolName); + profile.Grade = Trim(request.Grade); + profile.Tutor = Trim(request.Tutor); + profile.TransportMode = request.TransportMode; + profile.MorningTime = request.MorningTime; + profile.AfternoonTime = request.AfternoonTime; + profile.Notes = Trim(request.Notes); + + await _db.SaveChangesAsync(cancellationToken); + + return new SchoolProfileDto( + profile.SchoolName, + profile.Grade, + profile.Tutor, + profile.TransportMode, + profile.MorningTime, + profile.AfternoonTime, + profile.Notes); + } + + private static string? Trim(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/src/FamilyNido.Api/Features/Scores/GetMemberCompletionHistory.cs b/src/FamilyNido.Api/Features/Scores/GetMemberCompletionHistory.cs new file mode 100644 index 0000000..c2eb8d3 --- /dev/null +++ b/src/FamilyNido.Api/Features/Scores/GetMemberCompletionHistory.cs @@ -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.Scores; + +/// +/// Slice for GET /api/scores/members/{memberId}/history?limit=N. Returns +/// the most recent task completions of one member, ordered by completion +/// instant descending. Used by the per-member detail page to render the +/// "historial de tareas hechas" list. +/// +public static class GetMemberCompletionHistory +{ + /// Default page size โ€” fits comfortably in a /nido/:id section. + public const int DefaultLimit = 50; + + /// Hard cap to avoid pathological pulls. + public const int MaxLimit = 200; + + /// Query carrying the member id and an optional limit. + public sealed record Query(Guid MemberId, int Limit) : IRequest>>; + + /// Reads the rows. + public sealed class Handler : IRequestHandler>> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task>> 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 memberInFamily = await _db.FamilyMembers + .AnyAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, cancellationToken); + if (!memberInFamily) + { + return ApplicationError.NotFound("scores.unknown_member", "Member not found."); + } + + var limit = Math.Clamp(request.Limit <= 0 ? DefaultLimit : request.Limit, 1, MaxLimit); + + // Filter on the family of the owning task so a deleted member's + // completions across moved-out families never leak. Sorted by the + // completion instant โ€” that's the natural reading order. + var rows = await _db.TaskCompletions + .AsNoTracking() + .Where(c => c.CompletedByMemberId == request.MemberId + && c.Task!.FamilyId == current.Family.Id) + .OrderByDescending(c => c.CompletedAt) + .Take(limit) + .Select(c => new MemberCompletionDto( + c.TaskId, + c.Task!.Title, + c.OccurrenceDate, + c.CompletedAt, + c.Task!.Points)) + .ToListAsync(cancellationToken); + + return rows.AsReadOnly(); + } + } +} diff --git a/src/FamilyNido.Api/Features/Scores/GetMemberScore.cs b/src/FamilyNido.Api/Features/Scores/GetMemberScore.cs new file mode 100644 index 0000000..9d0bb89 --- /dev/null +++ b/src/FamilyNido.Api/Features/Scores/GetMemberScore.cs @@ -0,0 +1,77 @@ +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.Scores; + +/// +/// Slice for GET /api/scores/members/{memberId}. Returns a member's +/// reward totals for three rolling windows: this ISO week (Mondayโ†’Sunday in +/// the server's local time zone), this calendar month, and all-time. +/// +public static class GetMemberScore +{ + /// Query carrying the target member. + public sealed record Query(Guid MemberId) : IRequest>; + + /// Computes the totals. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _time; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider time) + { + _db = db; + _userContext = userContext; + _time = time; + } + + /// + public async Task> 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 memberInFamily = await _db.FamilyMembers + .AnyAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, cancellationToken); + if (!memberInFamily) + { + return ApplicationError.NotFound("scores.unknown_member", "Member not found."); + } + + // Compute the relevant date windows. "This week" anchors on Monday + // (Spanish convention); "this month" is the calendar month of today. + var today = DateOnly.FromDateTime(_time.GetLocalNow().DateTime); + var dow = (int)today.DayOfWeek; // Sunday = 0 + var daysFromMonday = dow == 0 ? 6 : dow - 1; + var weekStart = today.AddDays(-daysFromMonday); + var monthStart = new DateOnly(today.Year, today.Month, 1); + + // Single round-trip: pull every completion of this member with its + // task's points + occurrence date, then aggregate in memory. The + // sum of completions per member rarely exceeds a few hundred rows, + // so the cost is negligible and we avoid running three SQL queries. + var rows = await _db.TaskCompletions + .AsNoTracking() + .Where(c => c.CompletedByMemberId == request.MemberId + && c.Task!.FamilyId == current.Family.Id) + .Select(c => new { c.OccurrenceDate, Points = c.Task!.Points }) + .ToListAsync(cancellationToken); + + var thisWeek = rows.Where(r => r.OccurrenceDate >= weekStart).Sum(r => r.Points); + var thisMonth = rows.Where(r => r.OccurrenceDate >= monthStart).Sum(r => r.Points); + var allTime = rows.Sum(r => r.Points); + + return new MemberScoreDto(request.MemberId, thisWeek, thisMonth, allTime); + } + } +} diff --git a/src/FamilyNido.Api/Features/Scores/GetScoreboard.cs b/src/FamilyNido.Api/Features/Scores/GetScoreboard.cs new file mode 100644 index 0000000..3db6644 --- /dev/null +++ b/src/FamilyNido.Api/Features/Scores/GetScoreboard.cs @@ -0,0 +1,77 @@ +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.Scores; + +/// +/// Slice for GET /api/scores/leaderboard?from=&to=. Aggregates every +/// task completion in [from, to] (inclusive, by occurrence date) for the caller's +/// family, joining each completion to its task's current Points value to +/// produce a per-member sum. +/// +/// +/// History is intentionally mutable: editing a task's reward also reshapes past +/// totals because we read task.Points at query time rather than freezing +/// it on the completion row. +/// +public static class GetScoreboard +{ + /// Inclusive [From, To] range query. + public sealed record Query(DateOnly From, DateOnly To) : IRequest>; + + /// Computes the leaderboard. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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."); + } + if (request.To < request.From) + { + return ApplicationError.Validation("scores.bad_range", "End date must be on or after the start date."); + } + + var familyId = current.Family.Id; + + // Group by completing member, summing the task's current Points. + // Anonymous (member-deleted) completions land in a null bucket and + // are filtered out โ€” there's no scoreboard row to assign them to. + var rows = await _db.TaskCompletions + .AsNoTracking() + .Where(c => c.Task!.FamilyId == familyId + && c.OccurrenceDate >= request.From && c.OccurrenceDate <= request.To + && c.CompletedByMemberId != null) + .GroupBy(c => c.CompletedByMemberId!.Value) + .Select(g => new ScoreboardEntryDto( + g.Key, + g.Sum(c => c.Task!.Points), + g.Count())) + .ToListAsync(cancellationToken); + + var ordered = rows + .OrderByDescending(r => r.Points) + .ThenByDescending(r => r.CompletionCount) + .ToList(); + + return new LeaderboardDto(request.From, request.To, ordered); + } + } +} diff --git a/src/FamilyNido.Api/Features/Scores/ScoreDtos.cs b/src/FamilyNido.Api/Features/Scores/ScoreDtos.cs new file mode 100644 index 0000000..e24f6b6 --- /dev/null +++ b/src/FamilyNido.Api/Features/Scores/ScoreDtos.cs @@ -0,0 +1,40 @@ +namespace FamilyNido.Api.Features.Scores; + +/// One row of the family scoreboard for a date range. +/// Owning member. +/// Sum of HouseholdTask.Points across the member's completions in the range. +/// How many occurrences the member ticked in the range. +public sealed record ScoreboardEntryDto(Guid MemberId, int Points, int CompletionCount); + +/// Bundle returned by GET /api/scores/leaderboard. +/// Inclusive lower bound of the range. +/// Inclusive upper bound of the range. +/// Members ordered by points descending, ties broken by completion count. +public sealed record LeaderboardDto( + DateOnly From, + DateOnly To, + IReadOnlyList Entries); + +/// Per-member totals returned by GET /api/scores/members/{memberId}. +/// Owning member. +/// Sum of points for the current ISO week (Mondayโ†’Sunday in local time). +/// Sum of points for the current calendar month. +/// Sum of points across the member's whole history. +public sealed record MemberScoreDto( + Guid MemberId, + int ThisWeek, + int ThisMonth, + int AllTime); + +/// One row in the per-member completion history. +/// Owning task โ€” null when the task has been hard-deleted. +/// Title of the task at the time the row is read (or "(eliminada)"). +/// Date of the occurrence the member ticked. +/// UTC instant the row was created. +/// Reward earned (read live from HouseholdTask.Points; 0 when the task is gone). +public sealed record MemberCompletionDto( + Guid? TaskId, + string TaskTitle, + DateOnly OccurrenceDate, + DateTimeOffset CompletedAt, + int Points); diff --git a/src/FamilyNido.Api/Features/Scores/ScoreEndpoints.cs b/src/FamilyNido.Api/Features/Scores/ScoreEndpoints.cs new file mode 100644 index 0000000..919cd0b --- /dev/null +++ b/src/FamilyNido.Api/Features/Scores/ScoreEndpoints.cs @@ -0,0 +1,51 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; + +namespace FamilyNido.Api.Features.Scores; + +/// HTTP surface of the family scoreboard module. +public static class ScoreEndpoints +{ + /// Registers /api/scores/* routes. + public static IEndpointRouteBuilder MapScoreEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/scores").WithTags("Scores"); + + group.MapGet("/leaderboard", GetLeaderboardAsync).RequireAuthorization(Policies.AuthenticatedUser); + group.MapGet("/members/{memberId:guid}", GetMemberScoreAsync).RequireAuthorization(Policies.AuthenticatedUser); + group.MapGet("/members/{memberId:guid}/history", GetMemberHistoryAsync).RequireAuthorization(Policies.AuthenticatedUser); + + return app; + } + + private static async Task GetLeaderboardAsync( + DateOnly from, + DateOnly to, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new GetScoreboard.Query(from, to), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task GetMemberScoreAsync( + Guid memberId, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new GetMemberScore.Query(memberId), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task GetMemberHistoryAsync( + Guid memberId, + int? limit, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync( + new GetMemberCompletionHistory.Query(memberId, limit ?? GetMemberCompletionHistory.DefaultLimit), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } +} diff --git a/src/FamilyNido.Api/Features/Wall/AddWallComment.cs b/src/FamilyNido.Api/Features/Wall/AddWallComment.cs new file mode 100644 index 0000000..dc65122 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/AddWallComment.cs @@ -0,0 +1,112 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Features.Notifications; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Markdown; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Wall; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Wall; + +/// Slice: add a first-level reply to a wall message (RF-WALL-005). +public static class AddWallComment +{ + /// Command carrying the target message + text. + public sealed record Command(Guid MessageId, string Text) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.MessageId).NotEmpty(); + RuleFor(x => x.Text).NotEmpty().MaximumLength(2000); + } + } + + /// Handler โ€” renders markdown and persists. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly MarkdownRenderer _markdown; + private readonly NotificationService _notifications; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + MarkdownRenderer markdown, + NotificationService notifications) + { + _db = db; + _userContext = userContext; + _markdown = markdown; + _notifications = notifications; + } + + /// + public async Task> 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 messageExists = await _db.WallMessages + .AnyAsync(m => m.Id == request.MessageId && m.FamilyId == current.Family.Id, cancellationToken); + if (!messageExists) + { + return ApplicationError.NotFound("wall_message.not_found", $"Message {request.MessageId} not found."); + } + + var candidates = await _db.FamilyMembers + .AsNoTracking() + .Where(m => m.FamilyId == current.Family.Id && m.IsActive) + .Select(m => new MentionCandidate(m.Id, m.DisplayName)) + .ToListAsync(cancellationToken); + + var rendered = _markdown.RenderWithMentions(request.Text, candidates); + if (string.IsNullOrEmpty(rendered.Markdown)) + { + return ApplicationError.Validation("wall_comment.empty_text", "Comment text cannot be empty."); + } + + var comment = new WallComment + { + MessageId = request.MessageId, + AuthorMemberId = current.Member.Id, + Text = rendered.Markdown, + TextHtml = rendered.Html, + }; + + foreach (var memberId in rendered.MentionedMemberIds) + { + comment.Mentions.Add(new WallCommentMention { CommentId = comment.Id, FamilyMemberId = memberId }); + } + + _db.WallComments.Add(comment); + await _db.SaveChangesAsync(cancellationToken); + + var dto = WallCommentDto.From(comment); + + if (rendered.MentionedMemberIds.Count > 0) + { + var snippet = EmailTemplates.MakeSnippet(rendered.Markdown); + await _notifications.NotifyWallMentionAsync( + rendered.MentionedMemberIds, + current.Member.Id, + "comment", + snippet, + cancellationToken); + } + + return dto; + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/CreateWallMessage.cs b/src/FamilyNido.Api/Features/Wall/CreateWallMessage.cs new file mode 100644 index 0000000..cd34374 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/CreateWallMessage.cs @@ -0,0 +1,130 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Features.Notifications; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Markdown; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Wall; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Wall; + +/// Slice: publish a new wall message (RF-WALL-001). Renders markdown. +public static class CreateWallMessage +{ + /// Command carrying the payload. + /// Raw markdown. 1..4000 chars. + /// Optional image attachment (must belong to the caller's family). + public sealed record Command(string Text, Guid? ImageFileId) + : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.Text).NotEmpty().MaximumLength(4000); + } + } + + /// Handler โ€” persists the message and renders markdown. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly MarkdownRenderer _markdown; + private readonly NotificationService _notifications; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + MarkdownRenderer markdown, + NotificationService notifications) + { + _db = db; + _userContext = userContext; + _markdown = markdown; + _notifications = notifications; + } + + /// + public async Task> 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 (request.ImageFileId is { } imageId) + { + var imageOk = await _db.FileAssets + .AnyAsync(a => a.Id == imageId && a.FamilyId == current.Family.Id, cancellationToken); + if (!imageOk) + { + return ApplicationError.Validation( + "wall_message.invalid_image", + "Attached image does not belong to this family."); + } + } + + // Active members of the caller's family are the only mention candidates โ€” + // pulling them once keeps the renderer pure and avoids a DB hit for each match. + var candidates = await _db.FamilyMembers + .AsNoTracking() + .Where(m => m.FamilyId == current.Family.Id && m.IsActive) + .Select(m => new MentionCandidate(m.Id, m.DisplayName)) + .ToListAsync(cancellationToken); + + var rendered = _markdown.RenderWithMentions(request.Text, candidates); + if (string.IsNullOrEmpty(rendered.Markdown)) + { + return ApplicationError.Validation("wall_message.empty_text", "Message text cannot be empty."); + } + + var message = new WallMessage + { + FamilyId = current.Family.Id, + AuthorMemberId = current.Member.Id, + Text = rendered.Markdown, + TextHtml = rendered.Html, + ImageFileId = request.ImageFileId, + }; + + foreach (var memberId in rendered.MentionedMemberIds) + { + message.Mentions.Add(new WallMessageMention { MessageId = message.Id, FamilyMemberId = memberId }); + } + + _db.WallMessages.Add(message); + await _db.SaveChangesAsync(cancellationToken); + + // Reload with Include so the DTO carries image + collections (empty here). + var created = await _db.WallMessages + .AsNoTracking() + .Include(m => m.ImageFile) + .Include(m => m.Comments) + .Include(m => m.Reactions) + .FirstAsync(m => m.Id == message.Id, cancellationToken); + + var dto = WallMessageDto.From(created); + + if (rendered.MentionedMemberIds.Count > 0) + { + var snippet = EmailTemplates.MakeSnippet(rendered.Markdown); + await _notifications.NotifyWallMentionAsync( + rendered.MentionedMemberIds, + current.Member.Id, + "message", + snippet, + cancellationToken); + } + + return dto; + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/DeleteWallComment.cs b/src/FamilyNido.Api/Features/Wall/DeleteWallComment.cs new file mode 100644 index 0000000..a2274a0 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/DeleteWallComment.cs @@ -0,0 +1,65 @@ +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.Wall; + +/// Slice: delete a first-level reply. Only the author or an admin (RF-WALL-009 spirit). +public static class DeleteWallComment +{ + /// Command carrying the target comment id. + public sealed record Command(Guid CommentId) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 comment = await _db.WallComments + .Include(c => c.Message) + .FirstOrDefaultAsync( + c => c.Id == request.CommentId && c.Message!.FamilyId == current.Family.Id, + cancellationToken); + + if (comment is null) + { + return ApplicationError.NotFound("wall_comment.not_found", $"Comment {request.CommentId} not found."); + } + + var isAdmin = current.User.Role == FamilyRole.Admin; + var isAuthor = current.Member.Id == comment.AuthorMemberId; + if (!isAdmin && !isAuthor) + { + return ApplicationError.Forbidden( + "wall_comment.only_author_or_admin_can_delete", + "Only the author or a family admin may delete a comment."); + } + + _db.WallComments.Remove(comment); + await _db.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/DeleteWallMessage.cs b/src/FamilyNido.Api/Features/Wall/DeleteWallMessage.cs new file mode 100644 index 0000000..d39e6c5 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/DeleteWallMessage.cs @@ -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.Domain.Families; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Wall; + +/// Slice: hard-delete a wall message. Only the author or an admin (RF-WALL-009). +public static class DeleteWallMessage +{ + /// Command carrying the target id. + public sealed record Command(Guid MessageId) : IRequest>; + + /// Handler โ€” enforces author-or-admin rule. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 message = await _db.WallMessages + .FirstOrDefaultAsync( + m => m.Id == request.MessageId && m.FamilyId == current.Family.Id, + cancellationToken); + + if (message is null) + { + return ApplicationError.NotFound("wall_message.not_found", $"Message {request.MessageId} not found."); + } + + var isAdmin = current.User.Role == FamilyRole.Admin; + var isAuthor = current.Member.Id == message.AuthorMemberId; + if (!isAdmin && !isAuthor) + { + return ApplicationError.Forbidden( + "wall_message.only_author_or_admin_can_delete", + "Only the author or a family admin may delete a message."); + } + + _db.WallMessages.Remove(message); + await _db.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/GetWallMessage.cs b/src/FamilyNido.Api/Features/Wall/GetWallMessage.cs new file mode 100644 index 0000000..6eadf39 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/GetWallMessage.cs @@ -0,0 +1,52 @@ +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.Wall; + +/// Slice: fetch a single wall message by id, scoped to the caller's family. +public static class GetWallMessage +{ + /// Query carrying the target id. + public sealed record Query(Guid MessageId) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 message = await _db.WallMessages + .AsNoTracking() + .Include(m => m.ImageFile) + .Include(m => m.Comments) + .Include(m => m.Reactions) + .FirstOrDefaultAsync( + m => m.Id == request.MessageId && m.FamilyId == current.Family.Id, + cancellationToken); + + return message is null + ? ApplicationError.NotFound("wall_message.not_found", $"Message {request.MessageId} not found.") + : WallMessageDto.From(message); + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/GetWallUnreadCount.cs b/src/FamilyNido.Api/Features/Wall/GetWallUnreadCount.cs new file mode 100644 index 0000000..0160749 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/GetWallUnreadCount.cs @@ -0,0 +1,59 @@ +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.Wall; + +/// +/// Slice: count messages on the wall that post-date the caller's +/// LastWallReadAt watermark (RF-WALL-010). Messages authored by the caller +/// do not count โ€” you cannot have unread mail from yourself. +/// +public static class GetWallUnreadCount +{ + /// Parameterless query โ€” all inputs come from the authenticated user. + public sealed record Query : IRequest>; + + /// Response carrying the numeric count. + /// Unread messages for the caller. + public sealed record UnreadCountDto(int Count); + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 watermark = current.User.LastWallReadAt ?? DateTimeOffset.MinValue; + var myMemberId = current.Member.Id; + + var count = await _db.WallMessages + .AsNoTracking() + .Where(m => m.FamilyId == current.Family.Id + && m.CreatedAt > watermark + && m.AuthorMemberId != myMemberId) + .CountAsync(cancellationToken); + + return new UnreadCountDto(count); + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/ListWallMessages.cs b/src/FamilyNido.Api/Features/Wall/ListWallMessages.cs new file mode 100644 index 0000000..a8a0d3d --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/ListWallMessages.cs @@ -0,0 +1,97 @@ +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.Wall; + +/// +/// Slice: list wall messages for the caller's family (RF-WALL-001..006). Cursor-based +/// over CreatedAt descending. Pinned messages live in a separate bucket so the +/// UI can render them above the feed without interleaving. +/// +public static class ListWallMessages +{ + /// Page size default when the caller does not specify one. + public const int DefaultLimit = 20; + + /// Maximum page size accepted (anything higher is clamped). + public const int MaxLimit = 50; + + /// Query carrying the cursor. + /// Cursor: return only messages strictly older than this instant. + /// Page size; clamped to . + public sealed record Query(DateTimeOffset? Before, int? Limit) : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 limit = Math.Clamp(request.Limit ?? DefaultLimit, 1, MaxLimit); + + var pinned = await _db.WallMessages + .AsNoTracking() + .Include(m => m.ImageFile) + .Include(m => m.Comments) + .Include(m => m.Reactions) + .Where(m => m.FamilyId == current.Family.Id && m.IsPinned) + .OrderByDescending(m => m.PinnedAt) + .ToListAsync(cancellationToken); + + var feedQuery = _db.WallMessages + .AsNoTracking() + .Include(m => m.ImageFile) + .Include(m => m.Comments) + .Include(m => m.Reactions) + .Where(m => m.FamilyId == current.Family.Id && !m.IsPinned); + + if (request.Before is { } before) + { + feedQuery = feedQuery.Where(m => m.CreatedAt < before); + } + + // Fetch one extra row to know cheaply whether more pages exist. + var fetched = await feedQuery + .OrderByDescending(m => m.CreatedAt) + .Take(limit + 1) + .ToListAsync(cancellationToken); + + var hasMore = fetched.Count > limit; + var page = fetched.Take(limit).ToList(); + + return new WallFeedPageDto( + Pinned: [.. pinned.Select(WallMessageDto.From)], + Messages: [.. page.Select(WallMessageDto.From)], + HasMore: hasMore); + } + } +} + +/// One page of the wall feed: the full pinned list plus a cursor-paginated message window. +/// Always-full list of pinned messages (typically few). +/// Non-pinned messages newest first. +/// True when another page is available. +public sealed record WallFeedPageDto( + IReadOnlyList Pinned, + IReadOnlyList Messages, + bool HasMore); diff --git a/src/FamilyNido.Api/Features/Wall/PinWallMessage.cs b/src/FamilyNido.Api/Features/Wall/PinWallMessage.cs new file mode 100644 index 0000000..7e1c750 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/PinWallMessage.cs @@ -0,0 +1,66 @@ +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.Wall; + +/// Slice: mark a wall message as pinned (RF-WALL-003). Any adult may pin. +public static class PinWallMessage +{ + /// Command carrying the target id. + public sealed record Command(Guid MessageId) : IRequest>; + + /// Handler โ€” idempotent pinning. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _clock; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + TimeProvider clock) + { + _db = db; + _userContext = userContext; + _clock = clock; + } + + /// + public async Task> 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 message = await _db.WallMessages + .Include(m => m.ImageFile) + .Include(m => m.Comments) + .Include(m => m.Reactions) + .FirstOrDefaultAsync( + m => m.Id == request.MessageId && m.FamilyId == current.Family.Id, + cancellationToken); + + if (message is null) + { + return ApplicationError.NotFound("wall_message.not_found", $"Message {request.MessageId} not found."); + } + + if (!message.IsPinned) + { + message.IsPinned = true; + message.PinnedAt = _clock.GetUtcNow(); + await _db.SaveChangesAsync(cancellationToken); + } + + return WallMessageDto.From(message); + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/PreviewWallMarkdown.cs b/src/FamilyNido.Api/Features/Wall/PreviewWallMarkdown.cs new file mode 100644 index 0000000..8c987f0 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/PreviewWallMarkdown.cs @@ -0,0 +1,79 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Markdown; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Wall; + +/// +/// Slice for POST /api/wall/preview. Renders raw markdown using the same +/// pipeline that uses on save, so the composer +/// can show a faithful WYSIWYG preview without re-implementing markdown on the +/// client. Mentions are resolved against the caller's family roster. +/// +public static class PreviewWallMarkdown +{ + /// Command carrying the raw markdown text. + public sealed record Command(string Text) : IRequest>; + + /// Wire shape โ€” only the rendered HTML is useful to the client. + public sealed record PreviewDto(string Html); + + /// Input validation โ€” caps the size to match the create slice. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with the size rule. + public Validator() + { + RuleFor(x => x.Text).MaximumLength(4000); + } + } + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly MarkdownRenderer _markdown; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + MarkdownRenderer markdown) + { + _db = db; + _userContext = userContext; + _markdown = markdown; + } + + /// + public async Task> 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."); + } + + // Empty text โ†’ empty preview, no DB hit needed. + if (string.IsNullOrWhiteSpace(request.Text)) + { + return new PreviewDto(string.Empty); + } + + var candidates = await _db.FamilyMembers + .AsNoTracking() + .Where(m => m.FamilyId == current.Family.Id && m.IsActive) + .Select(m => new MentionCandidate(m.Id, m.DisplayName)) + .ToListAsync(cancellationToken); + + var rendered = _markdown.RenderWithMentions(request.Text, candidates); + return new PreviewDto(rendered.Html); + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/ToggleWallReaction.cs b/src/FamilyNido.Api/Features/Wall/ToggleWallReaction.cs new file mode 100644 index 0000000..053ba83 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/ToggleWallReaction.cs @@ -0,0 +1,126 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Wall; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Wall; + +/// +/// Slice: add or remove a single emoji reaction from the caller on a wall message +/// (RF-WALL-004). Called the same way in both directions โ€” the handler checks the +/// existing state and flips it. +/// +public static class ToggleWallReaction +{ + /// Command carrying the target message + emoji. + public sealed record Command(Guid MessageId, string Emoji) : IRequest>; + + /// Result after toggling: reports the final direction + the updated summary bucket. + /// Message affected. + /// Emoji toggled. + /// True if the caller now has a reaction, false if it was retired. + /// Aggregated bucket for this emoji after the toggle. + public sealed record ToggleResultDto( + Guid MessageId, + string Emoji, + bool IsReacted, + WallReactionSummaryDto Summary); + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.MessageId).NotEmpty(); + RuleFor(x => x.Emoji).NotEmpty().MaximumLength(16); + } + } + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _clock; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + TimeProvider clock) + { + _db = db; + _userContext = userContext; + _clock = clock; + } + + /// + public async Task> 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 message = await _db.WallMessages + .FirstOrDefaultAsync( + m => m.Id == request.MessageId && m.FamilyId == current.Family.Id, + cancellationToken); + if (message is null) + { + return ApplicationError.NotFound("wall_message.not_found", $"Message {request.MessageId} not found."); + } + + var existing = await _db.WallReactions + .FirstOrDefaultAsync( + r => r.MessageId == request.MessageId + && r.MemberId == current.Member.Id + && r.Emoji == request.Emoji, + cancellationToken); + + bool isReacted; + if (existing is not null) + { + _db.WallReactions.Remove(existing); + isReacted = false; + } + else + { + _db.WallReactions.Add(new WallReaction + { + MessageId = request.MessageId, + MemberId = current.Member.Id, + Emoji = request.Emoji, + ReactedAt = _clock.GetUtcNow(), + }); + isReacted = true; + } + + await _db.SaveChangesAsync(cancellationToken); + + // Rebuild the summary bucket for this emoji after the mutation. + var remaining = await _db.WallReactions + .AsNoTracking() + .Where(r => r.MessageId == request.MessageId && r.Emoji == request.Emoji) + .Select(r => r.MemberId) + .ToListAsync(cancellationToken); + + var summary = new WallReactionSummaryDto( + Emoji: request.Emoji, + Count: remaining.Count, + MemberIds: remaining); + + return new ToggleResultDto( + MessageId: request.MessageId, + Emoji: request.Emoji, + IsReacted: isReacted, + Summary: summary); + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/UnpinWallMessage.cs b/src/FamilyNido.Api/Features/Wall/UnpinWallMessage.cs new file mode 100644 index 0000000..17d4553 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/UnpinWallMessage.cs @@ -0,0 +1,61 @@ +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.Wall; + +/// Slice: remove the pinned flag from a wall message (RF-WALL-003). +public static class UnpinWallMessage +{ + /// Command carrying the target id. + public sealed record Command(Guid MessageId) : IRequest>; + + /// Handler โ€” idempotent unpinning. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext) + { + _db = db; + _userContext = userContext; + } + + /// + public async Task> 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 message = await _db.WallMessages + .Include(m => m.ImageFile) + .Include(m => m.Comments) + .Include(m => m.Reactions) + .FirstOrDefaultAsync( + m => m.Id == request.MessageId && m.FamilyId == current.Family.Id, + cancellationToken); + + if (message is null) + { + return ApplicationError.NotFound("wall_message.not_found", $"Message {request.MessageId} not found."); + } + + if (message.IsPinned) + { + message.IsPinned = false; + message.PinnedAt = null; + await _db.SaveChangesAsync(cancellationToken); + } + + return WallMessageDto.From(message); + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/UpdateWallLastRead.cs b/src/FamilyNido.Api/Features/Wall/UpdateWallLastRead.cs new file mode 100644 index 0000000..049281f --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/UpdateWallLastRead.cs @@ -0,0 +1,56 @@ +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.Wall; + +/// +/// Slice: bump the caller's LastWallReadAt watermark to now, used to +/// reset the unread counter when the UI opens the wall (RF-WALL-010). Idempotent. +/// +public static class UpdateWallLastRead +{ + /// Parameterless command โ€” all inputs come from the authenticated user. + public sealed record Command : IRequest>; + + /// Handler. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly TimeProvider _clock; + + /// Primary constructor. + public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider clock) + { + _db = db; + _userContext = userContext; + _clock = clock; + } + + /// + public async Task> 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 user = await _db.Users + .FirstOrDefaultAsync(u => u.Id == current.User.Id, cancellationToken); + if (user is null) + { + return ApplicationError.NotFound("user.not_found", "Authenticated user row missing."); + } + + user.LastWallReadAt = _clock.GetUtcNow(); + await _db.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/UpdateWallMessage.cs b/src/FamilyNido.Api/Features/Wall/UpdateWallMessage.cs new file mode 100644 index 0000000..c37edda --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/UpdateWallMessage.cs @@ -0,0 +1,151 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Features.Notifications; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Markdown; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using FamilyNido.Domain.Families; +using FamilyNido.Domain.Wall; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Api.Features.Wall; + +/// Slice: edit an existing wall message. Only the author or an admin may edit (RF-WALL-009 spirit). +public static class UpdateWallMessage +{ + /// Command carrying the id + new payload. + public sealed record Command( + Guid MessageId, + string Text, + Guid? ImageFileId) : IRequest>; + + /// Input validation. + public sealed class Validator : AbstractValidator + { + /// Creates the validator with all rules registered. + public Validator() + { + RuleFor(x => x.MessageId).NotEmpty(); + RuleFor(x => x.Text).NotEmpty().MaximumLength(4000); + } + } + + /// Handler โ€” updates and re-renders. + public sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _db; + private readonly ICurrentUserContext _userContext; + private readonly MarkdownRenderer _markdown; + private readonly NotificationService _notifications; + + /// Primary constructor. + public Handler( + ApplicationDbContext db, + ICurrentUserContext userContext, + MarkdownRenderer markdown, + NotificationService notifications) + { + _db = db; + _userContext = userContext; + _markdown = markdown; + _notifications = notifications; + } + + /// + public async Task> 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 message = await _db.WallMessages + .Include(m => m.ImageFile) + .Include(m => m.Comments) + .Include(m => m.Reactions) + .Include(m => m.Mentions) + .FirstOrDefaultAsync( + m => m.Id == request.MessageId && m.FamilyId == current.Family.Id, + cancellationToken); + + if (message is null) + { + return ApplicationError.NotFound("wall_message.not_found", $"Message {request.MessageId} not found."); + } + + var isAdmin = current.User.Role == FamilyRole.Admin; + var isAuthor = current.Member.Id == message.AuthorMemberId; + if (!isAdmin && !isAuthor) + { + return ApplicationError.Forbidden( + "wall_message.only_author_or_admin_can_edit", + "Only the author or a family admin may edit a message."); + } + + if (request.ImageFileId is { } imageId && imageId != message.ImageFileId) + { + var imageOk = await _db.FileAssets + .AnyAsync(a => a.Id == imageId && a.FamilyId == current.Family.Id, cancellationToken); + if (!imageOk) + { + return ApplicationError.Validation( + "wall_message.invalid_image", + "Attached image does not belong to this family."); + } + } + + var candidates = await _db.FamilyMembers + .AsNoTracking() + .Where(m => m.FamilyId == current.Family.Id && m.IsActive) + .Select(m => new MentionCandidate(m.Id, m.DisplayName)) + .ToListAsync(cancellationToken); + + var rendered = _markdown.RenderWithMentions(request.Text, candidates); + message.Text = rendered.Markdown; + message.TextHtml = rendered.Html; + message.ImageFileId = request.ImageFileId; + + // Capture the previous mention set so we can notify only newcomers โ€” + // editing a message should not re-spam people who were already pinged. + var previousMentionIds = message.Mentions.Select(x => x.FamilyMemberId).ToHashSet(); + + // Replace the mention set entirely โ€” easier than diffing add/remove. + message.Mentions.Clear(); + foreach (var memberId in rendered.MentionedMemberIds) + { + message.Mentions.Add(new WallMessageMention { MessageId = message.Id, FamilyMemberId = memberId }); + } + + await _db.SaveChangesAsync(cancellationToken); + + var newlyMentioned = rendered.MentionedMemberIds + .Where(id => !previousMentionIds.Contains(id)) + .ToList(); + if (newlyMentioned.Count > 0) + { + var snippet = EmailTemplates.MakeSnippet(rendered.Markdown); + await _notifications.NotifyWallMentionAsync( + newlyMentioned, + current.Member.Id, + "message", + snippet, + cancellationToken); + } + + // Reload to refresh ImageFile navigation with new FK. + var updated = await _db.WallMessages + .AsNoTracking() + .Include(m => m.ImageFile) + .Include(m => m.Comments) + .Include(m => m.Reactions) + .FirstAsync(m => m.Id == message.Id, cancellationToken); + + var dto = WallMessageDto.From(updated); + + return dto; + } + } +} diff --git a/src/FamilyNido.Api/Features/Wall/WallCommentDto.cs b/src/FamilyNido.Api/Features/Wall/WallCommentDto.cs new file mode 100644 index 0000000..43e3f3d --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/WallCommentDto.cs @@ -0,0 +1,28 @@ +using FamilyNido.Domain.Wall; + +namespace FamilyNido.Api.Features.Wall; + +/// Read-model projection of a returned by the API. +/// Stable comment identifier. +/// Message this comment belongs to. +/// Author (family member). +/// Raw markdown source. +/// Pre-rendered sanitized HTML. +/// UTC instant of creation. +public sealed record WallCommentDto( + Guid Id, + Guid MessageId, + Guid AuthorMemberId, + string Text, + string TextHtml, + DateTimeOffset CreatedAt) +{ + /// Project a domain entity to the DTO shape used by the API. + public static WallCommentDto From(WallComment c) => new( + Id: c.Id, + MessageId: c.MessageId, + AuthorMemberId: c.AuthorMemberId, + Text: c.Text, + TextHtml: c.TextHtml, + CreatedAt: c.CreatedAt); +} diff --git a/src/FamilyNido.Api/Features/Wall/WallEndpoints.cs b/src/FamilyNido.Api/Features/Wall/WallEndpoints.cs new file mode 100644 index 0000000..e25e453 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/WallEndpoints.cs @@ -0,0 +1,173 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; +using FluentValidation; + +namespace FamilyNido.Api.Features.Wall; + +/// REST endpoints for the wall (RF-WALL-*). +public static class WallEndpoints +{ + /// Registers /api/wall endpoints on the given route group. + public static IEndpointRouteBuilder MapWallEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/wall") + .WithTags("Wall") + .RequireAuthorization(Policies.AuthenticatedUser); + + group.MapGet("/messages", ListAsync); + group.MapGet("/messages/{id:guid}", GetAsync); + group.MapPost("/messages", CreateAsync); + group.MapPatch("/messages/{id:guid}", UpdateAsync); + group.MapDelete("/messages/{id:guid}", DeleteAsync); + group.MapPost("/messages/{id:guid}/pin", PinAsync); + group.MapPost("/messages/{id:guid}/unpin", UnpinAsync); + group.MapPost("/messages/{id:guid}/comments", AddCommentAsync); + group.MapDelete("/comments/{id:guid}", DeleteCommentAsync); + group.MapPost("/messages/{id:guid}/reactions", ToggleReactionAsync); + group.MapGet("/unread-count", UnreadCountAsync); + group.MapPatch("/last-read", LastReadAsync); + group.MapPost("/preview", PreviewAsync); + + return app; + } + + private static async Task PreviewAsync( + PreviewWallMarkdown.Command command, + IValidator 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 ListAsync( + DateTimeOffset? before, + int? limit, + IMediator mediator, + CancellationToken ct) + { + var result = await mediator.SendAsync(new ListWallMessages.Query(before, limit), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task GetAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new GetWallMessage.Query(id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task CreateAsync( + CreateWallMessage.Command command, + IValidator 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/wall/messages/{result.Value.Id}", result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task UpdateAsync( + Guid id, + UpdateWallMessageBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new UpdateWallMessage.Command(id, body.Text, body.ImageFileId); + 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 DeleteAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new DeleteWallMessage.Command(id), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } + + private static async Task PinAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new PinWallMessage.Command(id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task UnpinAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new UnpinWallMessage.Command(id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task AddCommentAsync( + Guid id, + AddWallCommentBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new AddWallComment.Command(id, body.Text); + 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/wall/comments/{result.Value.Id}", result.Value) + : result.Error.ToHttpResult(); + } + + private static async Task DeleteCommentAsync(Guid id, IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new DeleteWallComment.Command(id), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } + + private static async Task ToggleReactionAsync( + Guid id, + ToggleReactionBody body, + IValidator validator, + IMediator mediator, + CancellationToken ct) + { + var command = new ToggleWallReaction.Command(id, body.Emoji); + 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 UnreadCountAsync(IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new GetWallUnreadCount.Query(), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } + + private static async Task LastReadAsync(IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new UpdateWallLastRead.Command(), ct); + return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult(); + } +} + +/// Wire payload for PATCH /api/wall/messages/{id}. +/// New markdown source. +/// Optional image attachment. +public sealed record UpdateWallMessageBody(string Text, Guid? ImageFileId); + +/// Wire payload for POST /api/wall/messages/{id}/comments. +/// Comment text as markdown. +public sealed record AddWallCommentBody(string Text); + +/// Wire payload for POST /api/wall/messages/{id}/reactions. +/// Emoji to toggle. +public sealed record ToggleReactionBody(string Emoji); diff --git a/src/FamilyNido.Api/Features/Wall/WallMessageDto.cs b/src/FamilyNido.Api/Features/Wall/WallMessageDto.cs new file mode 100644 index 0000000..6d882bc --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/WallMessageDto.cs @@ -0,0 +1,47 @@ +using FamilyNido.Api.Features.Files; +using FamilyNido.Domain.Wall; + +namespace FamilyNido.Api.Features.Wall; + +/// Read-model projection of a returned by the API. +/// Stable message identifier. +/// Author (family member). +/// Raw markdown source (kept for edits). +/// Pre-rendered sanitized HTML to show to the user. +/// Attached image metadata with loadable URL, if any. +/// Whether the message is in the pinned zone. +/// When the message was pinned, if any. +/// UTC instant of creation. +/// First-level replies, newest first. +/// Aggregated reactions (emoji + count + members). +public sealed record WallMessageDto( + Guid Id, + Guid AuthorMemberId, + string Text, + string TextHtml, + FileAssetDto? Image, + bool IsPinned, + DateTimeOffset? PinnedAt, + DateTimeOffset CreatedAt, + IReadOnlyList Comments, + IReadOnlyList Reactions) +{ + /// Project a loaded domain entity to the DTO shape used by the API. + public static WallMessageDto From(WallMessage m) => new( + Id: m.Id, + AuthorMemberId: m.AuthorMemberId, + Text: m.Text, + TextHtml: m.TextHtml, + Image: m.ImageFile is null ? null : FileAssetDto.From(m.ImageFile), + IsPinned: m.IsPinned, + PinnedAt: m.PinnedAt, + CreatedAt: m.CreatedAt, + Comments: [.. m.Comments.OrderBy(c => c.CreatedAt).Select(WallCommentDto.From)], + Reactions: [.. m.Reactions + .GroupBy(r => r.Emoji) + .Select(g => new WallReactionSummaryDto( + Emoji: g.Key, + Count: g.Count(), + MemberIds: [.. g.Select(r => r.MemberId)])) + .OrderByDescending(s => s.Count)]); +} diff --git a/src/FamilyNido.Api/Features/Wall/WallReactionSummaryDto.cs b/src/FamilyNido.Api/Features/Wall/WallReactionSummaryDto.cs new file mode 100644 index 0000000..2ef6bc6 --- /dev/null +++ b/src/FamilyNido.Api/Features/Wall/WallReactionSummaryDto.cs @@ -0,0 +1,13 @@ +namespace FamilyNido.Api.Features.Wall; + +/// +/// Aggregated reaction bucket for a specific emoji on a message. Carries the count +/// plus the ids of members who reacted, so the UI can render "Dan, Ana + 3 mรกs". +/// +/// The emoji string (e.g. "โค๏ธ"). +/// Number of distinct members who reacted with this emoji. +/// Ids of the reacting members, in no particular order. +public sealed record WallReactionSummaryDto( + string Emoji, + int Count, + IReadOnlyList MemberIds); diff --git a/src/FamilyNido.Api/Features/Weather/GetWeatherToday.cs b/src/FamilyNido.Api/Features/Weather/GetWeatherToday.cs new file mode 100644 index 0000000..d331824 --- /dev/null +++ b/src/FamilyNido.Api/Features/Weather/GetWeatherToday.cs @@ -0,0 +1,122 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Errors; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Api.Shared.Outcomes; +using Microsoft.Extensions.Caching.Memory; + +namespace FamilyNido.Api.Features.Weather; + +/// +/// Slice for GET /api/weather/today. Resolves the family location, +/// hits Open-Meteo through , and projects the +/// upstream response into the compact widget DTO. Responses are cached for +/// 30 minutes per (latitude, longitude) so a busy household reloading +/// the dashboard never melts the upstream service. +/// +public static class GetWeatherToday +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30); + + /// Query carries no payload โ€” the family's location is resolved server-side. + public sealed record Query : IRequest>; + + /// Handler with in-memory cache + graceful degradation when upstream fails. + public sealed class Handler : IRequestHandler> + { + private readonly ICurrentUserContext _userContext; + private readonly WeatherClient _client; + private readonly IMemoryCache _cache; + + /// Primary constructor. + public Handler(ICurrentUserContext userContext, WeatherClient client, IMemoryCache cache) + { + _userContext = userContext; + _client = client; + _cache = cache; + } + + /// + public async Task> 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 family = current.Family; + if (family.Latitude is not { } lat || family.Longitude is not { } lon) + { + return ApplicationError.NotFound("weather.no_location", "Family location is not configured."); + } + + // Cache key keeps coordinates and the timezone โ€” sunrise/sunset depend + // on the timezone, so a TZ change must invalidate the cached value. + // The label is NOT keyed by language: we cache the WMO code and + // re-resolve the localized label per request, so two members of + // the same family with different PreferredLanguage hit one entry + // each and read it in their own tongue. + var lang = current.User.PreferredLanguage; + var cacheKey = $"weather:{lat:F3},{lon:F3}:{family.TimeZone}"; + if (_cache.TryGetValue(cacheKey, out var cached) && cached is not null) + { + return WithLocale(cached, family.LocationLabel ?? string.Empty, lang); + } + + var response = await _client.GetForecastAsync(lat, lon, family.TimeZone, cancellationToken); + if (response?.Current is null || response.Daily is null + || response.Daily.Max is null || response.Daily.Max.Count == 0 + || response.Daily.Min is null || response.Daily.Min.Count == 0) + { + return ApplicationError.NotFound("weather.upstream_failed", "Could not reach the weather provider."); + } + + var code = response.Current.WeatherCode + ?? (response.Daily.WeatherCode is { Count: > 0 } w ? w[0] : 0); + var (label, emoji) = WeatherCodes.Describe(code, lang); + + var dto = new WeatherTodayDto( + LocationLabel: family.LocationLabel ?? string.Empty, + CurrentTemperature: Math.Round(response.Current.Temperature ?? response.Daily.Max[0], 1), + ApparentTemperature: response.Current.ApparentTemperature is { } apt ? Math.Round(apt, 1) : null, + MaxTemperature: Math.Round(response.Daily.Max[0], 1), + MinTemperature: Math.Round(response.Daily.Min[0], 1), + WeatherCode: code, + WeatherLabel: label, + WeatherIcon: emoji, + PrecipitationProbability: response.Daily.PrecipitationProbabilityMax is { Count: > 0 } pp ? pp[0] : null, + Sunrise: response.Daily.Sunrise is { Count: > 0 } sr ? FormatLocalTime(sr[0]) : null, + Sunset: response.Daily.Sunset is { Count: > 0 } ss ? FormatLocalTime(ss[0]) : null); + + _cache.Set(cacheKey, dto, CacheTtl); + return dto; + } + + /// Open-Meteo returns sunrise/sunset as "yyyy-MM-ddTHH:mm" in the requested timezone. + private static string FormatLocalTime(string raw) + { + // We only need the HH:mm slice โ€” the date part is always today + // because we asked for forecast_days=1. + var t = raw.LastIndexOf('T'); + if (t < 0 || t + 6 > raw.Length) return raw; + return raw.Substring(t + 1, 5); + } + + /// + /// Patch the cached payload with the (mutable) family label and the + /// caller's localized weather label / icon. The cache stores the WMO + /// code as the source of truth, so the description is always derived + /// fresh from . + /// + private static WeatherTodayDto WithLocale(WeatherTodayDto cached, string locationLabel, string lang) + { + var (label, emoji) = WeatherCodes.Describe(cached.WeatherCode, lang); + return cached with + { + LocationLabel = locationLabel, + WeatherLabel = label, + WeatherIcon = emoji, + }; + } + } +} diff --git a/src/FamilyNido.Api/Features/Weather/WeatherClient.cs b/src/FamilyNido.Api/Features/Weather/WeatherClient.cs new file mode 100644 index 0000000..8946810 --- /dev/null +++ b/src/FamilyNido.Api/Features/Weather/WeatherClient.cs @@ -0,0 +1,83 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace FamilyNido.Api.Features.Weather; + +/// +/// Thin client over the public Open-Meteo forecast endpoint. The API needs no +/// authentication and is free for self-hosted use, so we only forward the +/// coordinates and the family's IANA timezone (so sunrise/sunset land in the +/// local clock rather than UTC). +/// +public sealed class WeatherClient +{ + private const string Endpoint = "https://api.open-meteo.com/v1/forecast"; + + private readonly HttpClient _http; + private readonly ILogger _logger; + + /// Primary constructor. + public WeatherClient(HttpClient http, ILogger logger) + { + _http = http; + _logger = logger; + } + + /// + /// Fetch a today-only forecast for /. + /// Returns null on transport or upstream errors so callers can degrade + /// gracefully โ€” there's no point bubbling up a 5xx for a decorative widget. + /// + /// Decimal degrees in [-90, 90]. + /// Decimal degrees in [-180, 180]. + /// IANA timezone passed to Open-Meteo so sunrise/sunset come in local time. + /// Cancellation token from the request scope. + public async Task GetForecastAsync( + double latitude, + double longitude, + string ianaTimeZone, + CancellationToken cancellationToken) + { + // Compose the URL inline โ€” Open-Meteo expects flat query strings, not + // a structured body, and using HttpClient with QueryHelpers would only + // add ceremony for what is essentially a four-parameter GET. + var url = $"{Endpoint}?latitude={latitude.ToString(System.Globalization.CultureInfo.InvariantCulture)}" + + $"&longitude={longitude.ToString(System.Globalization.CultureInfo.InvariantCulture)}" + + $"¤t=temperature_2m,apparent_temperature,weather_code" + + $"&daily=temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset,precipitation_probability_max" + + $"&forecast_days=1" + + $"&timezone={Uri.EscapeDataString(ianaTimeZone)}"; + + try + { + return await _http.GetFromJsonAsync(url, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Open-Meteo request failed for {Lat},{Lon}", latitude, longitude); + return null; + } + } + + /// Subset of the Open-Meteo response surface used by the widget. + /// Latest "current weather" snapshot. + /// One-day arrays (each list has length 1 because of forecast_days=1). + public sealed record OpenMeteoResponse( + [property: JsonPropertyName("current")] CurrentBlock? Current, + [property: JsonPropertyName("daily")] DailyBlock? Daily); + + /// "current" block โ€” single instantaneous reading. + public sealed record CurrentBlock( + [property: JsonPropertyName("temperature_2m")] double? Temperature, + [property: JsonPropertyName("apparent_temperature")] double? ApparentTemperature, + [property: JsonPropertyName("weather_code")] int? WeatherCode); + + /// "daily" block โ€” arrays indexed by day. + public sealed record DailyBlock( + [property: JsonPropertyName("temperature_2m_max")] List? Max, + [property: JsonPropertyName("temperature_2m_min")] List? Min, + [property: JsonPropertyName("weather_code")] List? WeatherCode, + [property: JsonPropertyName("sunrise")] List? Sunrise, + [property: JsonPropertyName("sunset")] List? Sunset, + [property: JsonPropertyName("precipitation_probability_max")] List? PrecipitationProbabilityMax); +} diff --git a/src/FamilyNido.Api/Features/Weather/WeatherCodes.cs b/src/FamilyNido.Api/Features/Weather/WeatherCodes.cs new file mode 100644 index 0000000..f016d00 --- /dev/null +++ b/src/FamilyNido.Api/Features/Weather/WeatherCodes.cs @@ -0,0 +1,38 @@ +using FamilyNido.Api.Features.Notifications; + +namespace FamilyNido.Api.Features.Weather; + +/// +/// Maps WMO weather codes (the integer Open-Meteo returns) to a localised +/// short label and a single emoji. The full WMO code book has dozens of +/// shades; we collapse them into the bands a family glances at on a phone. +/// +public static class WeatherCodes +{ + /// Resolve to a (label, emoji) tuple in the caller's language. + /// WMO weather code from Open-Meteo. + /// BCP-47 tag of the recipient (typically ). + public static (string Label, string Emoji) Describe(int code, string lang) + { + var (key, emoji) = code switch + { + 0 => ("weather.code.0", "โ˜€๏ธ"), + 1 => ("weather.code.1", "๐ŸŒค๏ธ"), + 2 => ("weather.code.2", "โ›…"), + 3 => ("weather.code.3", "โ˜๏ธ"), + 45 or 48 => ("weather.code.fog", "๐ŸŒซ๏ธ"), + 51 or 53 or 55 => ("weather.code.drizzle", "๐ŸŒฆ๏ธ"), + 56 or 57 => ("weather.code.freezing-drizzle", "๐ŸŒง๏ธ"), + 61 or 63 or 65 => ("weather.code.rain", "๐ŸŒง๏ธ"), + 66 or 67 => ("weather.code.freezing-rain", "๐ŸŒง๏ธ"), + 71 or 73 or 75 => ("weather.code.snow", "๐ŸŒจ๏ธ"), + 77 => ("weather.code.sleet", "๐ŸŒจ๏ธ"), + 80 or 81 or 82 => ("weather.code.showers", "๐ŸŒง๏ธ"), + 85 or 86 => ("weather.code.snow-showers", "๐ŸŒจ๏ธ"), + 95 => ("weather.code.thunderstorm", "โ›ˆ๏ธ"), + 96 or 99 => ("weather.code.thunderstorm-hail", "โ›ˆ๏ธ"), + _ => ("weather.code.unknown", "๐ŸŒก๏ธ"), + }; + return (BackendLocalization.T(key, lang), emoji); + } +} diff --git a/src/FamilyNido.Api/Features/Weather/WeatherDto.cs b/src/FamilyNido.Api/Features/Weather/WeatherDto.cs new file mode 100644 index 0000000..1c962e4 --- /dev/null +++ b/src/FamilyNido.Api/Features/Weather/WeatherDto.cs @@ -0,0 +1,30 @@ +namespace FamilyNido.Api.Features.Weather; + +/// +/// Compact "today's weather" payload returned by GET /api/weather/today. +/// Built from a single Open-Meteo forecast call so the frontend stays decoupled +/// from the upstream provider's wire format. +/// +/// Family's location label (e.g. "Bilbao"). Empty when not configured. +/// Current temperature in ยฐC. +/// "Feels like" temperature in ยฐC, or null when not available. +/// Today's maximum temperature in ยฐC. +/// Today's minimum temperature in ยฐC. +/// WMO weather code (0..99). Drives the icon and label. +/// Localised label derived from . +/// Single emoji used by the dashboard widget. +/// Highest hourly probability of precipitation today (0..100), or null. +/// Local time of sunrise, or null when not available. +/// Local time of sunset, or null when not available. +public sealed record WeatherTodayDto( + string LocationLabel, + double CurrentTemperature, + double? ApparentTemperature, + double MaxTemperature, + double MinTemperature, + int WeatherCode, + string WeatherLabel, + string WeatherIcon, + int? PrecipitationProbability, + string? Sunrise, + string? Sunset); diff --git a/src/FamilyNido.Api/Features/Weather/WeatherEndpoints.cs b/src/FamilyNido.Api/Features/Weather/WeatherEndpoints.cs new file mode 100644 index 0000000..066010b --- /dev/null +++ b/src/FamilyNido.Api/Features/Weather/WeatherEndpoints.cs @@ -0,0 +1,23 @@ +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Shared.Http; +using FamilyNido.Api.Shared.Mediator; + +namespace FamilyNido.Api.Features.Weather; + +/// Endpoint exposing the dashboard weather widget data. +public static class WeatherEndpoints +{ + /// Registers GET /api/weather/today. + public static IEndpointRouteBuilder MapWeatherEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/weather").WithTags("Weather"); + group.MapGet("/today", GetTodayAsync).RequireAuthorization(Policies.AuthenticatedUser); + return app; + } + + private static async Task GetTodayAsync(IMediator mediator, CancellationToken ct) + { + var result = await mediator.SendAsync(new GetWeatherToday.Query(), ct); + return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult(); + } +} diff --git a/src/FamilyNido.Api/Options/CalendarOptions.cs b/src/FamilyNido.Api/Options/CalendarOptions.cs new file mode 100644 index 0000000..5d9f75e --- /dev/null +++ b/src/FamilyNido.Api/Options/CalendarOptions.cs @@ -0,0 +1,53 @@ +namespace FamilyNido.Api.Options; + +/// +/// Configuration for the Google Calendar integration. The OAuth credentials are +/// shared across the family instance โ€” every adult who links their Google account +/// reuses the same client. Refresh tokens are persisted per-account, encrypted by +/// ASP.NET Core Data Protection. +/// +public sealed class CalendarOptions +{ + /// Configuration section name. + public const string SectionName = "Calendar"; + + /// OAuth client id from Google Cloud Console. + public string GoogleClientId { get; init; } = string.Empty; + + /// OAuth client secret from Google Cloud Console. + public string GoogleClientSecret { get; init; } = string.Empty; + + /// + /// Public URL of the OAuth callback. Must match exactly the "Authorized redirect + /// URIs" entry registered for the OAuth client. In dev typically + /// https://localhost:5001/api/calendar/google/callback; in prod + /// https://familia.example.com/api/calendar/google/callback. + /// + public string OAuthRedirectUri { get; init; } = string.Empty; + + /// + /// Frontend route the user lands on after the OAuth dance, regardless of success + /// or failure. Defaults to /calendario/cuentas so the linking UI re-renders + /// with the new account (or an error banner). + /// + public string PostAuthRedirectPath { get; init; } = "/calendario/cuentas"; + + /// + /// Period of the background sync job. Defaults to 15 minutes per RF-CAL-004; can + /// be lowered in dev to validate the pipeline faster. + /// + public TimeSpan SyncInterval { get; init; } = TimeSpan.FromMinutes(15); + + /// + /// Lower bound (relative to now) for events imported during a full sync. Anything + /// older is skipped โ€” a long-lived Google account would otherwise pull thousands + /// of stale events on first sync. Defaults to 90 days back. + /// + public TimeSpan FullSyncLookback { get; init; } = TimeSpan.FromDays(90); + + /// + /// Upper bound (relative to now) for events imported during a full sync. Defaults + /// to 365 days forward โ€” Google's incremental sync will keep up after that. + /// + public TimeSpan FullSyncLookahead { get; init; } = TimeSpan.FromDays(365); +} diff --git a/src/FamilyNido.Api/Options/CorsOptions.cs b/src/FamilyNido.Api/Options/CorsOptions.cs new file mode 100644 index 0000000..b65c1ff --- /dev/null +++ b/src/FamilyNido.Api/Options/CorsOptions.cs @@ -0,0 +1,11 @@ +namespace FamilyNido.Api.Options; + +/// Strongly-typed binding for the Cors configuration section. +public sealed class CorsOptions +{ + /// Configuration section name. + public const string SectionName = "Cors"; + + /// Allowed origins for the Angular SPA during development. + public IReadOnlyList AllowedOrigins { get; init; } = []; +} diff --git a/src/FamilyNido.Api/Options/EmailOptions.cs b/src/FamilyNido.Api/Options/EmailOptions.cs new file mode 100644 index 0000000..70a987f --- /dev/null +++ b/src/FamilyNido.Api/Options/EmailOptions.cs @@ -0,0 +1,68 @@ +namespace FamilyNido.Api.Options; + +/// +/// Outbound email configuration. FamilyNido never accepts mail; this section +/// only describes how the API talks to a generic SMTP relay (Brevo, Gmail +/// app-password, mailcow, an internal MTAโ€ฆ). Disabled by default so the +/// system stays usable without an email infrastructure: when +/// is false the sender becomes a no-op and admins +/// fall back to copying invitation links manually. +/// +public sealed class EmailOptions +{ + /// Configuration section name. + public const string SectionName = "Email"; + + /// + /// Master switch. When false the API registers NullEmailSender and + /// every send call returns Delivered=false. Useful for offline dev + /// and for emergencies in prod. + /// + public bool Enabled { get; init; } = false; + + /// SMTP host name. Required when is true. + public string SmtpHost { get; init; } = ""; + + /// SMTP port. 587 for STARTTLS submission, 465 for implicit TLS, 25 for plain. + public int SmtpPort { get; init; } = 587; + + /// Username for SMTP AUTH. Empty disables auth (open relay or trusted network). + public string SmtpUsername { get; init; } = ""; + + /// Password / app password for SMTP AUTH. Stored as plain config โ€” keep out of source control. + public string SmtpPassword { get; init; } = ""; + + /// + /// When true the connection is opened plain and upgraded to TLS via + /// STARTTLS (the standard for port 587). When false the client + /// connects and treats the port's TLS posture according to MailKit's + /// auto-detection (implicit TLS for 465, plain for 25). + /// + public bool SmtpUseStartTls { get; init; } = true; + + /// + /// "From" header used on outgoing messages. RFC 5322 mailbox syntax, + /// e.g. FamilyNido <noreply@example.com>. + /// + public string From { get; init; } = "FamilyNido "; + + /// + /// Public origin used to build invitation and email links. No trailing + /// slash. E.g. https://familia.example.com in production. + /// Operators MUST set this to the front-facing host; the default points + /// at the local Angular dev server so a fresh checkout doesn't hand out + /// dead links, but it's still wrong for prod / CI / any other env. + /// + public string AppBaseUrl { get; init; } = "http://localhost:4200"; + + /// How long an invitation token remains redeemable. Default 7 days. + public TimeSpan InvitationLifetime { get; init; } = TimeSpan.FromDays(7); + + /// + /// Hour-of-day (0..23) in each family's local timezone at which the daily + /// digest is allowed to fire. The background scheduler wakes every few + /// minutes and only emails when the local clock has crossed this threshold + /// for the first time on a given day. Default 7 (= 7:00 local). + /// + public int DigestHour { get; init; } = 7; +} diff --git a/src/FamilyNido.Api/Options/FamilyOptions.cs b/src/FamilyNido.Api/Options/FamilyOptions.cs new file mode 100644 index 0000000..e14cf4d --- /dev/null +++ b/src/FamilyNido.Api/Options/FamilyOptions.cs @@ -0,0 +1,26 @@ +namespace FamilyNido.Api.Options; + +/// Strongly-typed binding for the Family configuration section. +public sealed class FamilyOptions +{ + /// Configuration section name. + public const string SectionName = "Family"; + + /// Name given to the family when auto-bootstrapped on first login. + public string DefaultName { get; init; } = "Mi familia"; + + /// IANA time zone assigned to a newly bootstrapped family. + public string DefaultTimeZone { get; init; } = "Europe/Madrid"; + + /// BCP-47 locale assigned to a newly bootstrapped family. + public string DefaultLocale { get; init; } = "es-ES"; + + /// Apply pending EF Core migrations automatically at startup. + public bool AutoMigrate { get; init; } = true; + + /// + /// When true and the database is empty, the very first authenticated user + /// becomes the admin of a freshly created family. See RF-AUTH-003. + /// + public bool BootstrapFirstUserAsAdmin { get; init; } = true; +} diff --git a/src/FamilyNido.Api/Options/FilesOptions.cs b/src/FamilyNido.Api/Options/FilesOptions.cs new file mode 100644 index 0000000..af55ef8 --- /dev/null +++ b/src/FamilyNido.Api/Options/FilesOptions.cs @@ -0,0 +1,35 @@ +namespace FamilyNido.Api.Options; + +/// +/// Configuration for the file-asset storage used by the wall and (future) other +/// modules that need to attach binary content (health records, recipes, etc.). +/// Files are stored under with a module-scoped subfolder +/// (e.g. wall/YYYY/MM/<uuid>.jpg) and served back through authenticated +/// API endpoints โ€” never directly via the web server โ€” so per-family authorization +/// can run on every access. +/// +public sealed class FilesOptions +{ + /// Configuration section name. + public const string SectionName = "Files"; + + /// + /// Absolute directory where files are persisted. Mapped to a Docker volume + /// in prod (familynido-files:/app/data/files in + /// deploy/docker-compose.prod.yml); overridden to a local relative + /// path in dev via appsettings.Development.json. + /// + public string StorageRoot { get; init; } = "/app/data/files"; + + /// Maximum accepted size for an uploaded image, in bytes. Defaults to 10 MB. + public long MaxImageBytes { get; init; } = 10L * 1024 * 1024; + + /// + /// Accepted MIME types for image uploads. Includes HEIC/HEIF so iOS Photos + /// uploads work without forcing the user to switch the camera format to + /// "Most Compatible". Browsers other than Safari may not render HEIC inline, + /// but at least the upload itself does not get rejected at the API boundary. + /// + public string[] AllowedImageTypes { get; init; } = + ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]; +} diff --git a/src/FamilyNido.Api/Options/OidcOptions.cs b/src/FamilyNido.Api/Options/OidcOptions.cs new file mode 100644 index 0000000..4ba65aa --- /dev/null +++ b/src/FamilyNido.Api/Options/OidcOptions.cs @@ -0,0 +1,37 @@ +namespace FamilyNido.Api.Options; + +/// Strongly-typed binding for the Oidc configuration section. +public sealed class OidcOptions +{ + /// Configuration section name. + public const string SectionName = "Oidc"; + + /// + /// OIDC issuer URL (e.g. PocketID). Leave empty to disable the OIDC + /// login path entirely โ€” the login screen will then offer only local + /// credentials and /api/auth/login will not be reachable. + /// + public string Authority { get; init; } = ""; + + /// OAuth 2.0 client id registered with the provider. + public string ClientId { get; init; } = ""; + + /// Client secret. Leave empty for public PKCE-only clients. + public string ClientSecret { get; init; } = ""; + + /// Scopes requested on authentication. + public IReadOnlyList Scopes { get; init; } = ["openid", "profile", "email"]; + + /// Relative path where the provider redirects after login. + public string CallbackPath { get; init; } = "/signin-oidc"; + + /// Relative path where the provider redirects after logout. + public string SignedOutCallbackPath { get; init; } = "/signout-callback-oidc"; + + /// + /// Whether the metadata document must be served over HTTPS. Defaults to + /// true. Override to false only if you point at a + /// plain-HTTP local IdP for testing. + /// + public bool RequireHttpsMetadata { get; init; } = true; +} diff --git a/src/FamilyNido.Api/Program.cs b/src/FamilyNido.Api/Program.cs new file mode 100644 index 0000000..f09e39d --- /dev/null +++ b/src/FamilyNido.Api/Program.cs @@ -0,0 +1,307 @@ +using System.Text.Json.Serialization; +using System.Threading.RateLimiting; +using FamilyNido.Api.Features.Auth; +using FamilyNido.Api.Features.Calendar; +using FamilyNido.Api.Features.Dashboard; +using FamilyNido.Api.Features.Families; +using FamilyNido.Api.Features.FamilyMembers; +using FamilyNido.Api.Features.Files; +using FamilyNido.Api.Features.Health; +using FamilyNido.Api.Features.HouseholdTasks; +using FamilyNido.Api.Features.Integrations; +using FamilyNido.Api.Features.Invitations; +using FamilyNido.Api.Features.Meals; +using FamilyNido.Api.Features.MemberAgenda; +using FamilyNido.Api.Features.Notifications; +using FamilyNido.Api.Features.PublicApi; +using FamilyNido.Api.Features.School; +using FamilyNido.Api.Features.Scores; +using FamilyNido.Api.Features.Wall; +using FamilyNido.Api.Features.Weather; +using FamilyNido.Api.Bootstrap; +using FamilyNido.Api.Options; +using FamilyNido.Api.Shared.Markdown; +using FamilyNido.Api.Shared.Mediator; +using FamilyNido.Domain.Identity; +using FamilyNido.Persistence; +using FluentValidation; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +// โ”€โ”€ Structured logging โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +builder.Host.UseSerilog((ctx, sp, logger) => logger + .ReadFrom.Configuration(ctx.Configuration) + .ReadFrom.Services(sp)); + +// โ”€โ”€ Options โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(OidcOptions.SectionName)) + .ValidateOnStart(); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(FamilyOptions.SectionName)) + .ValidateOnStart(); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(CorsOptions.SectionName)); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(FilesOptions.SectionName)); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(CalendarOptions.SectionName)); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(EmailOptions.SectionName)); + +// โ”€โ”€ Persistence (DbContext + TimeProvider) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +builder.Services.AddFamilyNidoPersistence(builder.Configuration); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + +// โ”€โ”€ Mediator + validators (scan the current assembly) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +builder.Services.AddMediator(typeof(Program).Assembly); +builder.Services.AddValidatorsFromAssemblyContaining(); + +// Markdown renderer is pure + stateless โ€” singleton keeps the Markdig pipeline cached. +builder.Services.AddSingleton(); + +// โ”€โ”€ Calendar (Google sync) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// HttpClientFactory powers the OAuth token endpoint calls; it is also a hard +// dependency of GoogleOAuthService. +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + +// โ”€โ”€ Local-credential password hashing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// PasswordHasher is stateless and thread-safe โ€” singleton lifetime is +// the right call. Default IdentityV3 (PBKDF2 with SHA-512, 100k iterations). +builder.Services.AddSingleton, PasswordHasher>(); + +// โ”€โ”€ Rate limiting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Local login is the only password-touching endpoint and the obvious target +// for credential stuffing. 5 attempts per 5-minute window per remote IP is a +// stingy-but-fair limit for a family-sized app. +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.AddPolicy(AuthEndpoints.LocalLoginRateLimitPolicy, http => + { + var key = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(5), + QueueLimit = 0, + }); + }); + + // Public API is partitioned by remote IP โ€” the API-key claim isn't + // available yet because rate limiting runs ahead of authentication, and + // partitioning by IP also doubles as a defence against enumerating + // tokens (an attacker firing random keys at the endpoint is still bound + // by the same bucket). 60 req/min/IP fits the worst legitimate burst โ€” + // an automation firing a couple of times an hour โ€” with + // plenty of headroom. + options.AddPolicy(PublicApiEndpoints.RateLimitPolicy, http => + { + var key = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 60, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + }); + }); +}); + +// โ”€โ”€ Email โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// The sender is selected at startup based on Email:Enabled so handlers don't +// need to branch: when email is off, NullEmailSender silently logs and +// reports Delivered=false, and "copy invitation link" UX is the safety net. +var emailEnabled = builder.Configuration + .GetSection(EmailOptions.SectionName) + .GetValue(nameof(EmailOptions.Enabled)); +if (emailEnabled) +{ + builder.Services.AddSingleton(); +} +else +{ + builder.Services.AddSingleton(); +} + +// โ”€โ”€ Weather (Open-Meteo) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// IMemoryCache is used to keep a 30-min cache of the upstream forecast so the +// dashboard reload-storm of a busy household doesn't hammer api.open-meteo.com. +builder.Services.AddMemoryCache(); +builder.Services.AddHttpClient(); + +// In-memory queue + drainer for fire-and-forget notification emails. +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +// Scoped because it uses the request-scoped DbContext to resolve recipients. +builder.Services.AddScoped(); +// Daily morning digest scheduler. Wakes every few minutes and respects each family's TZ. +builder.Services.AddHostedService(); + +// โ”€โ”€ HTTP pipeline services โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddOpenApi(); +builder.Services.AddProblemDetails(); + +// Serialize enums (MemberType, FamilyRole, RecurrenceMode, DayOfWeekMask flags) +// as strings so the Angular side can rely on stable string literals instead of +// ordinals that shift whenever we reorder cases. +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); + +// โ”€โ”€ Authentication + authorization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +builder.Services.AddFamilyNidoAuthentication(builder.Configuration, builder.Environment); + +// โ”€โ”€ E2E test data seeder (Testing env + opt-in flag only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Plays no role in dev or prod: only registered when ASPNETCORE_ENVIRONMENT +// is "Testing". Even then it no-ops unless Seed:E2E:Enabled is true. Used +// by the Playwright suite to bootstrap a deterministic family + two adult +// users with local credentials. +if (builder.Environment.IsEnvironment("Testing")) +{ + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(E2ESeedOptions.SectionName)); + builder.Services.AddHostedService(); +} + +// โ”€โ”€ Demo data seeder (Development env + opt-in flag only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Drops a curated, screenshot-friendly scenario (one family, four members, +// tasks, wall posts, meals, school) into an empty database. Off by default +// even in Development. Only registered when ASPNETCORE_ENVIRONMENT is +// "Development" and no-ops unless Seed:Demo:Enabled is true. Never runs in +// production. Used to capture the README assets without touching real data. +if (builder.Environment.IsDevelopment()) +{ + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(DemoSeedOptions.SectionName)); + builder.Services.AddHostedService(); +} + +// โ”€โ”€ CORS for the Angular SPA during development โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const string SpaCorsPolicy = "spa"; +builder.Services.AddCors(options => +{ + options.AddPolicy(SpaCorsPolicy, policy => + { + var allowed = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get() ?? []; + policy.WithOrigins(allowed) + .WithHeaders("Content-Type", "Authorization", "X-Api-Key", "Accept") + .WithMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .AllowCredentials(); + }); +}); + +// โ”€โ”€ Health checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +builder.Services.AddHealthChecks() + .AddNpgSql( + connectionStringFactory: sp => sp.GetRequiredService().GetConnectionString("Postgres")!, + name: "postgres", + tags: ["ready"]); + +// โ”€โ”€ Forward headers (when running behind Traefik) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Restrict trust to RFC1918 private networks (the Docker bridge + LAN +// segments where Traefik realistically sits) plus loopback. Without this +// any client could spoof X-Forwarded-For and bypass IP-bound rate limits +// or impersonate HTTPS to the framework. +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); + options.KnownIPNetworks.Add(new System.Net.IPNetwork( + System.Net.IPAddress.Parse("10.0.0.0"), 8)); + options.KnownIPNetworks.Add(new System.Net.IPNetwork( + System.Net.IPAddress.Parse("172.16.0.0"), 12)); + options.KnownIPNetworks.Add(new System.Net.IPNetwork( + System.Net.IPAddress.Parse("192.168.0.0"), 16)); + options.KnownProxies.Add(System.Net.IPAddress.Loopback); + options.KnownProxies.Add(System.Net.IPAddress.IPv6Loopback); +}); + +var app = builder.Build(); + +// โ”€โ”€ Apply migrations at startup (opt-in) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +var familyOptions = app.Services.GetRequiredService>().Value; +if (familyOptions.AutoMigrate) +{ + await using var scope = app.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + +// โ”€โ”€ Pipeline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +app.UseForwardedHeaders(); +app.UseSerilogRequestLogging(); +app.UseExceptionHandler(); +app.UseStatusCodePages(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseCors(SpaCorsPolicy); +// Rate limiting is in real units (5 local-login attempts / 5 min per IP). +// The integration test suite shares one loopback IP across hundreds of +// requests, so we skip the middleware in the Testing environment to keep +// tests deterministic. Production and dev are unaffected. +if (!app.Environment.IsEnvironment("Testing")) +{ + app.UseRateLimiter(); +} +app.UseAuthentication(); +app.UseAuthorization(); + +// โ”€โ”€ Endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +app.MapAuthEndpoints(); +app.MapFamilyMemberEndpoints(); +app.MapHouseholdTaskEndpoints(); +app.MapWallEndpoints(); +app.MapFileEndpoints(); +app.MapCalendarEndpoints(); +app.MapMealEndpoints(); +app.MapInvitationEndpoints(); +app.MapNotificationEndpoints(); +app.MapFamilyEndpoints(); +app.MapWeatherEndpoints(); +app.MapHealthEndpoints(); +app.MapSchoolEndpoints(); +app.MapDashboardEndpoints(); +app.MapMemberAgendaEndpoints(); +app.MapScoreEndpoints(); +app.MapIntegrationEndpoints(); +app.MapPublicApiEndpoints(); + +app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions +{ + Predicate = _ => false, +}); +app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions +{ + Predicate = check => check.Tags.Contains("ready"), +}); + +app.Run(); + +/// Program entry point (partial enables test access via WebApplicationFactory<Program>). +public partial class Program; diff --git a/src/FamilyNido.Api/Properties/launchSettings.json b/src/FamilyNido.Api/Properties/launchSettings.json new file mode 100644 index 0000000..543069b --- /dev/null +++ b/src/FamilyNido.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/FamilyNido.Api/Shared/Errors/ApplicationError.cs b/src/FamilyNido.Api/Shared/Errors/ApplicationError.cs new file mode 100644 index 0000000..d0da5c4 --- /dev/null +++ b/src/FamilyNido.Api/Shared/Errors/ApplicationError.cs @@ -0,0 +1,43 @@ +namespace FamilyNido.Api.Shared.Errors; + +/// +/// Structured error returned by application handlers. Maps cleanly to HTTP +/// ProblemDetails without leaking infrastructure details. +/// +/// Machine-readable error code (e.g. "family_member.not_found"). +/// Human-readable message, safe to surface to the user. +/// Error kind that maps to a default HTTP status. +public sealed record ApplicationError(string Code, string Message, ErrorKind Kind) +{ + /// Requested resource does not exist. + public static ApplicationError NotFound(string code, string message) + => new(code, message, ErrorKind.NotFound); + + /// Caller is authenticated but lacks rights to perform the action. + public static ApplicationError Forbidden(string code, string message) + => new(code, message, ErrorKind.Forbidden); + + /// Input is structurally invalid or violates a domain rule. + public static ApplicationError Validation(string code, string message) + => new(code, message, ErrorKind.Validation); + + /// Operation conflicts with the current state (e.g. would break an invariant). + public static ApplicationError Conflict(string code, string message) + => new(code, message, ErrorKind.Conflict); +} + +/// Kinds of error that differ in their HTTP mapping. +public enum ErrorKind +{ + /// 400 Bad Request. + Validation, + + /// 403 Forbidden. + Forbidden, + + /// 404 Not Found. + NotFound, + + /// 409 Conflict. + Conflict, +} diff --git a/src/FamilyNido.Api/Shared/Http/NotLinkedProblem.cs b/src/FamilyNido.Api/Shared/Http/NotLinkedProblem.cs new file mode 100644 index 0000000..00754a9 --- /dev/null +++ b/src/FamilyNido.Api/Shared/Http/NotLinkedProblem.cs @@ -0,0 +1,16 @@ +namespace FamilyNido.Api.Shared.Http; + +/// +/// Factory for the "user is authenticated but not linked to a family member" +/// ProblemDetails response (RF-AUTH-003). The front-end uses the title +/// code auth.not_linked to route to the stop page. +/// +public static class NotLinkedProblem +{ + /// Return a 403 ProblemDetails with the canonical code. + public static IResult Create() => Results.Problem( + title: "auth.not_linked", + detail: "Your account is not linked to a family member. Ask the family admin to link you.", + statusCode: StatusCodes.Status403Forbidden, + type: "https://FamilyNido/errors/auth.not_linked"); +} diff --git a/src/FamilyNido.Api/Shared/Http/ProblemDetailsExtensions.cs b/src/FamilyNido.Api/Shared/Http/ProblemDetailsExtensions.cs new file mode 100644 index 0000000..bc5dd6f --- /dev/null +++ b/src/FamilyNido.Api/Shared/Http/ProblemDetailsExtensions.cs @@ -0,0 +1,35 @@ +using FamilyNido.Api.Shared.Errors; +using Microsoft.AspNetCore.Mvc; + +namespace FamilyNido.Api.Shared.Http; + +/// Maps values to RFC 7807 ProblemDetails. +public static class ProblemDetailsExtensions +{ + /// Convert an application error to a payload. + public static ProblemDetails ToProblemDetails(this ApplicationError error) => new() + { + Title = error.Code, + Detail = error.Message, + Status = error.Kind switch + { + ErrorKind.Validation => StatusCodes.Status400BadRequest, + ErrorKind.Forbidden => StatusCodes.Status403Forbidden, + ErrorKind.NotFound => StatusCodes.Status404NotFound, + ErrorKind.Conflict => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest, + }, + Type = $"https://FamilyNido/errors/{error.Code}", + }; + + /// Return a typed for an application error. + public static IResult ToHttpResult(this ApplicationError error) + { + var pd = error.ToProblemDetails(); + return Results.Problem( + detail: pd.Detail, + title: pd.Title, + statusCode: pd.Status, + type: pd.Type); + } +} diff --git a/src/FamilyNido.Api/Shared/Http/ValidationExtensions.cs b/src/FamilyNido.Api/Shared/Http/ValidationExtensions.cs new file mode 100644 index 0000000..cb5f09e --- /dev/null +++ b/src/FamilyNido.Api/Shared/Http/ValidationExtensions.cs @@ -0,0 +1,31 @@ +using FluentValidation; + +namespace FamilyNido.Api.Shared.Http; + +/// Minimal endpoint helpers for FluentValidation. +public static class ValidationExtensions +{ + /// + /// Validates the payload using FluentValidation and returns an + /// with ProblemDetails on failure, or null on success. + /// + public static async Task ValidateOrProblemAsync( + this IValidator validator, + T instance, + CancellationToken cancellationToken) + { + var result = await validator.ValidateAsync(instance, cancellationToken); + if (result.IsValid) + { + return null; + } + + var errors = result.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.ErrorMessage).ToArray()); + + return Results.ValidationProblem(errors); + } +} diff --git a/src/FamilyNido.Api/Shared/Markdown/MarkdownRenderer.cs b/src/FamilyNido.Api/Shared/Markdown/MarkdownRenderer.cs new file mode 100644 index 0000000..6acb6c4 --- /dev/null +++ b/src/FamilyNido.Api/Shared/Markdown/MarkdownRenderer.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Text.RegularExpressions; +using Markdig; + +namespace FamilyNido.Api.Shared.Markdown; + +/// One member that may appear after @ in markdown source. +/// Stable identifier โ€” drives notifications and links. +/// Exact display name to match (case-insensitive at compare time). +public sealed record MentionCandidate(Guid MemberId, string DisplayName); + +/// Rendered markdown plus the set of family members it mentions. +/// The trimmed markdown source. +/// Sanitized HTML; mentions wrapped in <span class="bx-mention">. +/// Distinct ids of every recognised @DisplayName. +public sealed record MarkdownRenderResult( + string Markdown, + string Html, + IReadOnlyList MentionedMemberIds); + +/// +/// Renders wall markdown into safe HTML. Wraps a single +/// with raw-HTML disabled so any embedded <script> or <iframe> +/// is escaped at source โ€” no downstream sanitizer is needed (RF-WALL-011). +/// +public sealed class MarkdownRenderer +{ + private readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder() + .UseEmphasisExtras() + .UseAutoLinks() + .UseSoftlineBreakAsHardlineBreak() + .DisableHtml() + .Build(); + + /// + /// Render to (markdown, html). The markdown string + /// is the trimmed original; the html is the sanitized rendering โ€” persist both so + /// the UI can show rendered content without re-rendering and the raw source is + /// kept for edits. + /// + /// Raw markdown typed by the user. + /// Tuple with the cleaned markdown source and the rendered HTML. + public (string Markdown, string Html) Render(string? input) + { + var clean = (input ?? string.Empty).Trim(); + var html = Markdig.Markdown.ToHtml(clean, _pipeline); + return (clean, html); + } + + /// + /// Render and detect @DisplayName references + /// against . Recognised mentions are wrapped in + /// <span class="bx-mention" data-member-id="โ€ฆ"> in the HTML so the + /// UI can style them, and their ids are returned for downstream notification + /// dispatch. Unknown names stay as plain text. + /// + /// Raw markdown typed by the user. + /// Family members eligible to be mentioned (typically active members of the caller's family). + /// Cleaned markdown, decorated HTML and the set of mentioned member ids. + public MarkdownRenderResult RenderWithMentions(string? input, IReadOnlyList candidates) + { + var clean = (input ?? string.Empty).Trim(); + var html = Markdig.Markdown.ToHtml(clean, _pipeline); + + if (candidates.Count == 0 || string.IsNullOrEmpty(clean)) + { + return new MarkdownRenderResult(clean, html, []); + } + + // Longest-first so greedy alternation matches "Marรญa Josรฉ" before "Marรญa". + var sorted = candidates + .GroupBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .OrderByDescending(c => c.DisplayName.Length) + .ToList(); + + var alternatives = string.Join("|", sorted.Select(c => Regex.Escape(c.DisplayName))); + // Boundaries are letters/digits โ€” leading boundary keeps email addresses + // (foo@bar.com) from matching, trailing keeps "@DanX" from matching "@Dan". + var pattern = $@"(?(); + foreach (Match m in regex.Matches(clean)) + { + var name = m.Groups[1].Value; + var member = sorted.First(c => + string.Equals(c.DisplayName, name, StringComparison.OrdinalIgnoreCase)); + mentioned.Add(member.MemberId); + } + + // Re-build pattern over HTML-encoded names because Markdig encodes "<&>" + // before we touch the output. Most names won't change, but this keeps us + // safe for names with apostrophes or accented characters Markdig touches. + var htmlSorted = sorted + .Select(c => new { c.MemberId, c.DisplayName, Encoded = WebUtility.HtmlEncode(c.DisplayName) }) + .ToList(); + var htmlAlternatives = string.Join("|", htmlSorted.Select(c => Regex.Escape(c.Encoded))); + var htmlPattern = $@"(? + { + var encodedName = match.Groups[1].Value; + var member = htmlSorted.First(c => + string.Equals(c.Encoded, encodedName, StringComparison.OrdinalIgnoreCase)); + return $"@{member.Encoded}"; + }); + + return new MarkdownRenderResult(clean, decoratedHtml, mentioned.ToArray()); + } +} diff --git a/src/FamilyNido.Api/Shared/Mediator/IMediator.cs b/src/FamilyNido.Api/Shared/Mediator/IMediator.cs new file mode 100644 index 0000000..06feec4 --- /dev/null +++ b/src/FamilyNido.Api/Shared/Mediator/IMediator.cs @@ -0,0 +1,37 @@ +namespace FamilyNido.Api.Shared.Mediator; + +/// +/// Marker for a request (command or query) that expects a response of +/// . Handlers are discovered via DI through +/// . +/// +/// Type returned by the matching handler. +#pragma warning disable CA1040 // Marker interface is intentional: acts as the typing link between request and handler. +public interface IRequest +{ +} +#pragma warning restore CA1040 + +/// +/// Handler for a specific . One handler per +/// request type is the expected shape. +/// +/// Request this handler consumes. +/// Response this handler produces. +public interface IRequestHandler + where TRequest : IRequest +{ + /// Handles the request and returns its response. + Task HandleAsync(TRequest request, CancellationToken cancellationToken); +} + +/// +/// Dispatches requests to their registered . +/// The implementation resolves the handler from on +/// each call, so handlers are effectively scoped to the current DI scope. +/// +public interface IMediator +{ + /// Send the request and await its handler response. + Task SendAsync(IRequest request, CancellationToken cancellationToken); +} diff --git a/src/FamilyNido.Api/Shared/Mediator/Mediator.cs b/src/FamilyNido.Api/Shared/Mediator/Mediator.cs new file mode 100644 index 0000000..7efa7da --- /dev/null +++ b/src/FamilyNido.Api/Shared/Mediator/Mediator.cs @@ -0,0 +1,44 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace FamilyNido.Api.Shared.Mediator; + +/// +/// Default . Resolves the concrete handler +/// () from the current scope +/// based on the runtime type of the request, caching the reflected +/// to avoid repeated lookup costs. +/// +internal sealed class Mediator : IMediator +{ + private static readonly ConcurrentDictionary<(Type RequestType, Type ResponseType), HandlerInvocation> Cache = new(); + + private readonly IServiceProvider _services; + + /// Primary constructor โ€” takes the ambient provider. + public Mediator(IServiceProvider services) => _services = services; + + /// + public Task SendAsync(IRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var requestType = request.GetType(); + var invocation = Cache.GetOrAdd( + (requestType, typeof(TResponse)), + static key => + { + var handlerType = typeof(IRequestHandler<,>).MakeGenericType(key.RequestType, key.ResponseType); + var method = handlerType.GetMethod(nameof(IRequestHandler, object>.HandleAsync)) + ?? throw new InvalidOperationException( + $"Handler type '{handlerType}' is missing the HandleAsync method."); + return new HandlerInvocation(handlerType, method); + }); + + var handler = _services.GetRequiredService(invocation.HandlerType); + return (Task)invocation.Method.Invoke(handler, [request, cancellationToken])!; + } + + private sealed record HandlerInvocation(Type HandlerType, MethodInfo Method); +} diff --git a/src/FamilyNido.Api/Shared/Mediator/MediatorServiceCollectionExtensions.cs b/src/FamilyNido.Api/Shared/Mediator/MediatorServiceCollectionExtensions.cs new file mode 100644 index 0000000..84f19ab --- /dev/null +++ b/src/FamilyNido.Api/Shared/Mediator/MediatorServiceCollectionExtensions.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace FamilyNido.Api.Shared.Mediator; + +/// DI registration helpers for the in-process mediator. +public static class MediatorServiceCollectionExtensions +{ + /// + /// Registers plus every concrete + /// implementation + /// found in as scoped services. + /// + /// Target service collection. + /// Assembly scanned for handler types. + /// The service collection for chaining. + public static IServiceCollection AddMediator(this IServiceCollection services, Assembly assembly) + { + services.AddScoped(); + + var handlerOpenType = typeof(IRequestHandler<,>); + + var registrations = assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .SelectMany(t => t.GetInterfaces(), (impl, iface) => (Impl: impl, Iface: iface)) + .Where(x => x.Iface.IsGenericType && x.Iface.GetGenericTypeDefinition() == handlerOpenType); + + foreach (var (impl, iface) in registrations) + { + services.AddScoped(iface, impl); + } + + return services; + } +} diff --git a/src/FamilyNido.Api/Shared/Outcomes/Result.cs b/src/FamilyNido.Api/Shared/Outcomes/Result.cs new file mode 100644 index 0000000..54f966f --- /dev/null +++ b/src/FamilyNido.Api/Shared/Outcomes/Result.cs @@ -0,0 +1,53 @@ +using FamilyNido.Api.Shared.Errors; + +namespace FamilyNido.Api.Shared.Outcomes; + +/// +/// Discriminated result used by application handlers: either a value or an +/// . Keeps error flow explicit without exceptions. +/// +/// Success value type. +public readonly record struct Result +{ + private readonly T? _value; + private readonly ApplicationError? _error; + + private Result(T value) + { + _value = value; + _error = null; + IsSuccess = true; + } + + private Result(ApplicationError error) + { + _value = default; + _error = error; + IsSuccess = false; + } + + /// True when the operation produced a value. + public bool IsSuccess { get; } + + /// Success value. Throws if accessed on a failure result. + public T Value => IsSuccess + ? _value! + : throw new InvalidOperationException("Cannot access Value on a failed Result."); + + /// Error detail. Throws if accessed on a success result. + public ApplicationError Error => !IsSuccess + ? _error! + : throw new InvalidOperationException("Cannot access Error on a successful Result."); + + /// Create a success result. + public static Result Success(T value) => new(value); + + /// Create a failure result. + public static Result Failure(ApplicationError error) => new(error); + + /// Implicit conversion from value โ€” terse success creation. + public static implicit operator Result(T value) => Success(value); + + /// Implicit conversion from error โ€” terse failure creation. + public static implicit operator Result(ApplicationError error) => Failure(error); +} diff --git a/src/FamilyNido.Api/Shared/Outcomes/Unit.cs b/src/FamilyNido.Api/Shared/Outcomes/Unit.cs new file mode 100644 index 0000000..fc45d05 --- /dev/null +++ b/src/FamilyNido.Api/Shared/Outcomes/Unit.cs @@ -0,0 +1,8 @@ +namespace FamilyNido.Api.Shared.Outcomes; + +/// Void placeholder returned by when there is no value. +public readonly record struct Unit +{ + /// Canonical singleton instance. + public static Unit Value { get; } +} diff --git a/src/FamilyNido.Api/appsettings.Development.json b/src/FamilyNido.Api/appsettings.Development.json new file mode 100644 index 0000000..098d873 --- /dev/null +++ b/src/FamilyNido.Api/appsettings.Development.json @@ -0,0 +1,37 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft.AspNetCore.Authentication": "Information" + } + } + }, + "Oidc": { + "Authority": "", + "ClientId": "", + "ClientSecret": "", + "RequireHttpsMetadata": false + }, + "Files": { + "StorageRoot": "./data/uploads" + }, + "Seed": { + "Demo": { + "Enabled": false, + "FamilyName": "Smith Family", + "TimeZone": "Europe/Madrid", + "Locale": "en-US", + "PreferredLanguage": "en-US", + "LocationLabel": "Bilbao", + "Latitude": 43.263, + "Longitude": -2.935, + "AdminEmail": "dan@familynido.demo", + "AdminDisplayName": "Dan", + "AdminPassword": "", + "PartnerEmail": "eve@familynido.demo", + "PartnerDisplayName": "Eve", + "PartnerPassword": "" + } + } +} diff --git a/src/FamilyNido.Api/appsettings.json b/src/FamilyNido.Api/appsettings.json new file mode 100644 index 0000000..5f39e79 --- /dev/null +++ b/src/FamilyNido.Api/appsettings.json @@ -0,0 +1,53 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Npgsql": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + } + } + ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ] + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5435;Database=familynido;Username=familynido;Password=familynido" + }, + "Oidc": { + "Authority": "", + "ClientId": "", + "ClientSecret": "", + "Scopes": [ "openid", "profile", "email" ], + "CallbackPath": "/signin-oidc", + "SignedOutCallbackPath": "/signout-callback-oidc", + "RequireHttpsMetadata": true + }, + "Cors": { + "AllowedOrigins": [ "http://localhost:4200" ] + }, + "Family": { + "DefaultName": "Mi familia", + "DefaultTimeZone": "Europe/Madrid", + "DefaultLocale": "es-ES", + "AutoMigrate": true, + "BootstrapFirstUserAsAdmin": true + }, + "Calendar": { + "GoogleClientId": "", + "GoogleClientSecret": "", + "OAuthRedirectUri": "https://localhost:5001/api/calendar/google/callback", + "PostAuthRedirectPath": "/calendario/cuentas", + "SyncInterval": "00:15:00", + "FullSyncLookback": "90.00:00:00", + "FullSyncLookahead": "365.00:00:00" + } +} diff --git a/src/FamilyNido.Domain/Agenda/AgendaTransportMode.cs b/src/FamilyNido.Domain/Agenda/AgendaTransportMode.cs new file mode 100644 index 0000000..01d51c1 --- /dev/null +++ b/src/FamilyNido.Domain/Agenda/AgendaTransportMode.cs @@ -0,0 +1,32 @@ +namespace FamilyNido.Domain.Agenda; + +/// +/// Means of transport used by a family member for an agenda entry. Drives the +/// icon shown on the dashboard widget and lays the groundwork for future +/// derivations like "the car is taken today". Independent of +/// on purpose: school commute is narrow +/// (kids' day-to-day), agenda covers the whole family with broader options. +/// +public enum AgendaTransportMode +{ + /// Unknown / not specified. + None = 0, + + /// Driving the family car. + Car = 1, + + /// Bus. + Bus = 2, + + /// Walking. + Walk = 3, + + /// Train. + Train = 4, + + /// Plane. + Plane = 5, + + /// Anything else (taxi, bike, ride from a friend). + Other = 6, +} diff --git a/src/FamilyNido.Domain/Agenda/MemberAgendaException.cs b/src/FamilyNido.Domain/Agenda/MemberAgendaException.cs new file mode 100644 index 0000000..c5943f3 --- /dev/null +++ b/src/FamilyNido.Domain/Agenda/MemberAgendaException.cs @@ -0,0 +1,63 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Agenda; + +/// +/// Per-date deviation from the recurring agenda. Has two flavours: +/// +/// Override ( set): cancels or modifies a +/// specific occurrence of an existing pattern. +/// true wipes that day for that pattern; otherwise the non-null fields +/// replace the pattern's defaults for that single day. +/// Ad-hoc ( null): a one-off entry that +/// doesn't repeat โ€” "hoy tambiรฉn voy a Mondragรณn aunque no es martes". +/// +/// +public sealed class MemberAgendaException : AuditableEntity +{ + /// Family the row belongs to (denormalised for fast queries). + public required Guid FamilyId { get; set; } + + /// Member the exception applies to. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the member. + public FamilyMember? FamilyMember { get; set; } + + /// Date the exception applies to. + public required DateOnly Date { get; set; } + + /// + /// When set, this row is an override of + /// with that id. Null = ad-hoc one-off entry. + /// + public Guid? PatternId { get; set; } + + /// Navigation to the overridden pattern, when applicable. + public MemberAgendaPattern? Pattern { get; set; } + + /// True when the day cancels the pattern (only valid when is set). + public bool IsCancelled { get; set; } + + /// Override label (or label of the ad-hoc entry). + public string? Label { get; set; } + + /// Override location. + public string? Location { get; set; } + + /// Override start time. + public TimeOnly? StartTime { get; set; } + + /// Override end time. + public TimeOnly? EndTime { get; set; } + + /// Override transport mode. Null = inherit from the pattern (or unknown for ad-hoc). + public AgendaTransportMode? TransportMode { get; set; } + + /// Override away flag. Null = inherit from the pattern (or true for ad-hoc). + public bool? IsAway { get; set; } + + /// Override notes. + public string? Notes { get; set; } +} diff --git a/src/FamilyNido.Domain/Agenda/MemberAgendaPattern.cs b/src/FamilyNido.Domain/Agenda/MemberAgendaPattern.cs new file mode 100644 index 0000000..f4888c8 --- /dev/null +++ b/src/FamilyNido.Domain/Agenda/MemberAgendaPattern.cs @@ -0,0 +1,54 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Agenda; + +/// +/// Recurring weekly entry in a family member's agenda โ€” "Alice goes to +/// Mondragรณn every Tuesday". Multiple rows are allowed for the same +/// (member, weekday) so the same day can carry several activities (e.g. work +/// in the morning + gym in the evening). +/// +/// +/// Per-date deviations (cancellations, time changes, ad-hoc additions) live +/// in . Resolution at the API layer merges +/// the two layers exactly like the school module does. +/// +public sealed class MemberAgendaPattern : AuditableEntity +{ + /// Family the entry belongs to (denormalised for fast queries). + public required Guid FamilyId { get; set; } + + /// Member this pattern describes. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the member. + public FamilyMember? FamilyMember { get; set; } + + /// Weekday the pattern applies to. + public required DayOfWeek DayOfWeek { get; set; } + + /// Short label shown in the dashboard widget ("Mondragรณn", "Gym"). + public required string Label { get; set; } + + /// Optional location ("Mondragรณn", "Polideportivo Atxuri"). Free text. + public string? Location { get; set; } + + /// Optional start time. Null = "all day". + public TimeOnly? StartTime { get; set; } + + /// Optional end time. Null = open-ended. + public TimeOnly? EndTime { get; set; } + + /// How the member moves that day. = unspecified. + public AgendaTransportMode TransportMode { get; set; } = AgendaTransportMode.None; + + /// True when the member is physically away from home (used to surface "hoy fuera" on the dashboard). + public bool IsAway { get; set; } = true; + + /// Optional free-text notes ("llevar comida en tupper", "saca el coche del garaje"). + public string? Notes { get; set; } + + /// True when the pattern is in force. Soft-disable lets you pause a pattern without losing it. + public bool IsActive { get; set; } = true; +} diff --git a/src/FamilyNido.Domain/Calendar/CalendarEvent.cs b/src/FamilyNido.Domain/Calendar/CalendarEvent.cs new file mode 100644 index 0000000..517a54c --- /dev/null +++ b/src/FamilyNido.Domain/Calendar/CalendarEvent.cs @@ -0,0 +1,66 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Calendar; + +/// +/// Mirrored Google Calendar event. Rows are owned by a +/// and refreshed by the periodic sync engine via incremental sync tokens. The app is +/// strictly read-only against this table; users edit events in Google Calendar and +/// the next sync brings the changes here. +/// +public sealed class CalendarEvent : AuditableEntity +{ + /// Family this event ultimately belongs to (denormalized for fast queries). + public required Guid FamilyId { get; set; } + + /// Navigation to the owning . + public Family? Family { get; set; } + + /// Calendar of origin within the linked Google account. + public required Guid LinkedCalendarId { get; set; } + + /// Navigation to the owning . + public LinkedCalendar? LinkedCalendar { get; set; } + + /// Google's event id. Unique within a calendar. + public required string ExternalEventId { get; set; } + + /// + /// iCalendar UID โ€” stable across recurrence instances and across calendars when an + /// event is shared. Useful for grouping and de-duplication in dashboards. + /// + public string? IcalUid { get; set; } + + /// Event title ("summary" in Google's API). + public required string Title { get; set; } + + /// Optional longer description. + public string? Description { get; set; } + + /// Optional location string. + public string? Location { get; set; } + + /// Start instant in UTC. For all-day events, the start is midnight in . + public required DateTimeOffset StartAt { get; set; } + + /// End instant in UTC. Google's convention: for all-day events, end is exclusive. + public required DateTimeOffset EndAt { get; set; } + + /// True when the event has no specific time-of-day (a "date" in Google's API instead of "dateTime"). + public bool IsAllDay { get; set; } + + /// Original IANA timezone reported by Google ("Europe/Madrid" by default for our users). + public string? OriginalTimeZone { get; set; } + + /// Public link back to the event in Google Calendar (htmlLink). + public string? HtmlLink { get; set; } + + /// + /// Locally-managed N:M of family members the event concerns ("a quiรฉn va") โ€” + /// independent from the calendar-level + /// default. Persisted in FamilyNido and survives Google re-syncs because the sync + /// engine upserts in-place on the same . + /// + public ICollection RelatedMembers { get; set; } = []; +} diff --git a/src/FamilyNido.Domain/Calendar/GoogleAccount.cs b/src/FamilyNido.Domain/Calendar/GoogleAccount.cs new file mode 100644 index 0000000..6b211d6 --- /dev/null +++ b/src/FamilyNido.Domain/Calendar/GoogleAccount.cs @@ -0,0 +1,51 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; +using FamilyNido.Domain.Identity; + +namespace FamilyNido.Domain.Calendar; + +/// +/// Google account linked by an adult member to mirror their Google Calendar +/// events into FamilyNido. Each user may link several accounts (personal + work, +/// shared family account, etc.). The refresh token is the only persisted credential +/// and is stored encrypted with ASP.NET Core Data Protection. +/// +public sealed class GoogleAccount : AuditableEntity +{ + /// Family this account belongs to (denormalized for fast filtering). + public required Guid FamilyId { get; set; } + + /// Navigation to the owning . + public Family? Family { get; set; } + + /// Owning user โ€” only that user may unlink the account. + public required Guid UserId { get; set; } + + /// Navigation to the owning . + public User? User { get; set; } + + /// Google account email. Surfaced in the UI to disambiguate multiple links. + public required string Email { get; set; } + + /// Display name reported by Google's user info endpoint, best effort. + public string? DisplayName { get; set; } + + /// + /// Refresh token persisted as ciphertext (Data Protection purpose + /// "calendar.google.refresh-token"). Decrypted only inside the sync + /// pipeline at request time. + /// + public required string EncryptedRefreshToken { get; set; } + + /// Last sync error message captured for diagnostics; null when healthy. + public string? LastError { get; set; } + + /// + /// True when Google rejected our refresh token (revoked by the user, password + /// change, scope removal). The UI must prompt the user to re-link. + /// + public bool IsRevoked { get; set; } + + /// Calendars discovered for this account; some may be marked as not imported. + public ICollection Calendars { get; set; } = []; +} diff --git a/src/FamilyNido.Domain/Calendar/LinkedCalendar.cs b/src/FamilyNido.Domain/Calendar/LinkedCalendar.cs new file mode 100644 index 0000000..e0fc235 --- /dev/null +++ b/src/FamilyNido.Domain/Calendar/LinkedCalendar.cs @@ -0,0 +1,64 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Calendar; + +/// +/// A specific Google Calendar imported under a . Each +/// row represents one calendar visible to that account (primary, shared, holidaysโ€ฆ). +/// Only those flagged with contribute events to the family +/// view; the rest are kept as discoverable metadata so the user can flip them on later. +/// +public sealed class LinkedCalendar : AuditableEntity +{ + /// Owning Google account. + public required Guid GoogleAccountId { get; set; } + + /// Navigation to the owning . + public GoogleAccount? GoogleAccount { get; set; } + + /// + /// Google's calendar identifier ("primary", an email, or + /// ...@group.calendar.google.com). Unique within an account. + /// + public required string ExternalCalendarId { get; set; } + + /// Human-readable name reported by Google. + public required string Summary { get; set; } + + /// Optional description from Google. + public string? Description { get; set; } + + /// + /// Hex color (#RRGGBB) reported by Google. Used as fallback when the + /// calendar is not assigned to a family member. + /// + public string? ColorHex { get; set; } + + /// + /// True when events from this calendar are mirrored into FamilyNido. Toggling off + /// purges the cached events on the next sync. + /// + public bool IsImported { get; set; } + + /// + /// Optional family member to which this calendar is associated. Drives the color + /// of the events in the UI. Null means "use the Google color". + /// + public Guid? FamilyMemberId { get; set; } + + /// Navigation to the assigned . + public FamilyMember? FamilyMember { get; set; } + + /// + /// Latest nextSyncToken returned by Google. Null forces a full sync on the + /// next run (initial import, after a 410 Gone, or after toggling import on). + /// + public string? SyncToken { get; set; } + + /// UTC instant of the last successful sync. Null until first sync. + public DateTimeOffset? LastSyncedAt { get; set; } + + /// Cached events mirrored from Google; cascade-deleted with the calendar. + public ICollection Events { get; set; } = []; +} diff --git a/src/FamilyNido.Domain/Common/AuditableEntity.cs b/src/FamilyNido.Domain/Common/AuditableEntity.cs new file mode 100644 index 0000000..6157f43 --- /dev/null +++ b/src/FamilyNido.Domain/Common/AuditableEntity.cs @@ -0,0 +1,23 @@ +namespace FamilyNido.Domain.Common; + +/// +/// Base for persisted entities that carry identity + auditing metadata. +/// Audit columns are populated automatically by the DbContext's SaveChanges override. +/// +public abstract class AuditableEntity +{ + /// Primary key. UUIDv7 for time-ordered, index-friendly ids. + public Guid Id { get; set; } = Guid.CreateVersion7(); + + /// UTC instant of row creation. + public DateTimeOffset CreatedAt { get; set; } + + /// OIDC subject (or "system") of the actor that created the row. + public string? CreatedBy { get; set; } + + /// UTC instant of the last mutation. Null until first update. + public DateTimeOffset? UpdatedAt { get; set; } + + /// OIDC subject (or "system") of the actor that last updated the row. + public string? UpdatedBy { get; set; } +} diff --git a/src/FamilyNido.Domain/Families/Family.cs b/src/FamilyNido.Domain/Families/Family.cs new file mode 100644 index 0000000..e230d37 --- /dev/null +++ b/src/FamilyNido.Domain/Families/Family.cs @@ -0,0 +1,32 @@ +using FamilyNido.Domain.Common; + +namespace FamilyNido.Domain.Families; + +/// +/// Aggregate root representing the family unit. Every tenant-scoped entity hangs +/// off a ; FamilyNido is single-family per deployment but the +/// model keeps this explicit so future multi-tenant variants stay feasible. +/// +public sealed class Family : AuditableEntity +{ + /// Display name of the family, shown in the header (e.g. "Familia Demo"). + public required string Name { get; set; } + + /// IANA time zone (e.g. "Europe/Madrid") used to render dates and trigger reminders. + public required string TimeZone { get; set; } + + /// BCP-47 locale used for formatting (e.g. "es-ES"). Individual users may override. + public string Locale { get; set; } = "es-ES"; + + /// Geographic latitude (decimal degrees). Drives the dashboard weather widget when set. + public double? Latitude { get; set; } + + /// Geographic longitude (decimal degrees). Always paired with . + public double? Longitude { get; set; } + + /// Human-readable label for the location (e.g. "Bilbao"). Displayed under the weather widget. + public string? LocationLabel { get; set; } + + /// Members of this family. Navigation property loaded on demand. + public ICollection Members { get; set; } = []; +} diff --git a/src/FamilyNido.Domain/Families/FamilyMember.cs b/src/FamilyNido.Domain/Families/FamilyMember.cs new file mode 100644 index 0000000..16cfc3c --- /dev/null +++ b/src/FamilyNido.Domain/Families/FamilyMember.cs @@ -0,0 +1,60 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Identity; + +namespace FamilyNido.Domain.Families; + +/// +/// A person belonging to the family. Central aggregate that other modules (calendar, +/// health, schoolโ€ฆ) point at as the "owner" of their items. +/// +/// +/// A member may or may not have an associated account: +/// only adults who actually log in are linked. Children and reference-only members +/// (grandparents, caretakers) stay as rows without a user. +/// +public sealed class FamilyMember : AuditableEntity +{ + /// Family this member belongs to. + public required Guid FamilyId { get; set; } + + /// Navigation to the owning . + public Family? Family { get; set; } + + /// Display name shown across the UI ("Dan", "Bob", etc.). + public required string DisplayName { get; set; } + + /// Date of birth if known. Drives age-derived UI decisions (e.g. school module). + public DateOnly? BirthDate { get; set; } + + /// Role within the family (adult/child/other). + public required MemberType MemberType { get; set; } + + /// + /// Relative path (inside the files volume) to this member's avatar. Null for default. + /// + public string? PhotoPath { get; set; } + + /// + /// Hex color used consistently across calendar, dashboard and avatars for this member. + /// Format: "#RRGGBB". Enforced by validation at the application layer. + /// + public required string ColorHex { get; set; } + + /// + /// Informational contact email (for reminders or sharing with the pediatrician). + /// Distinct from (OIDC identity). + /// + public string? ContactEmail { get; set; } + + /// Linked user account id. Null when this member does not authenticate. + public Guid? UserId { get; set; } + + /// Navigation to the linked . + public User? User { get; set; } + + /// + /// Soft-delete/archival flag. Inactive members are preserved for history but hidden + /// from standard selectors. + /// + public bool IsActive { get; set; } = true; +} diff --git a/src/FamilyNido.Domain/Families/FamilyRole.cs b/src/FamilyNido.Domain/Families/FamilyRole.cs new file mode 100644 index 0000000..43ff647 --- /dev/null +++ b/src/FamilyNido.Domain/Families/FamilyRole.cs @@ -0,0 +1,19 @@ +namespace FamilyNido.Domain.Families; + +/// +/// Authorization role attached to a User. Only adults with a linked user have a role. +/// +/// +/// Ordered so that higher numeric value implies broader permissions โ€” simplifies policy checks. +/// +public enum FamilyRole +{ + /// Very limited read-only access (e.g. grandparent who only views the calendar). + Guest = 0, + + /// Full access to all modules except critical configuration. + Adult = 1, + + /// Full access plus member management, integrations and destructive operations. + Admin = 2, +} diff --git a/src/FamilyNido.Domain/Families/MemberType.cs b/src/FamilyNido.Domain/Families/MemberType.cs new file mode 100644 index 0000000..a48f07a --- /dev/null +++ b/src/FamilyNido.Domain/Families/MemberType.cs @@ -0,0 +1,18 @@ +namespace FamilyNido.Domain.Families; + +/// +/// Classifies a family member by their relationship to the family unit. +/// Only members can be linked to a User and authenticate. +/// Children and "other" members exist as references for assignments (events, health, schoolโ€ฆ). +/// +public enum MemberType +{ + /// Adult member of the family (father, mother, partner). Can have a user account. + Adult = 0, + + /// Child member. Never authenticates, always appears as an assignment target. + Child = 1, + + /// Any other reference person (grandparents, caretakers). Does not authenticate. + Other = 2, +} diff --git a/src/FamilyNido.Domain/FamilyNido.Domain.csproj b/src/FamilyNido.Domain/FamilyNido.Domain.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/FamilyNido.Domain/FamilyNido.Domain.csproj @@ -0,0 +1,9 @@ +๏ปฟ + + + net10.0 + enable + enable + + + diff --git a/src/FamilyNido.Domain/Files/FileAsset.cs b/src/FamilyNido.Domain/Files/FileAsset.cs new file mode 100644 index 0000000..31d3f04 --- /dev/null +++ b/src/FamilyNido.Domain/Files/FileAsset.cs @@ -0,0 +1,43 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Files; + +/// +/// Binary asset (image, PDFโ€ฆ) owned by a family and stored on disk. Records the +/// authenticated metadata needed to serve the bytes back safely: owning family, +/// uploader, relative path under the configured storage root, content type and +/// size. Used initially for wall images; reused later by health and recipes. +/// +public sealed class FileAsset : AuditableEntity +{ + /// Family this asset belongs to (authorization boundary). + public required Guid FamilyId { get; set; } + + /// Navigation to the owning . + public Family? Family { get; set; } + + /// Member who uploaded the file. Retained for audit / attribution. + public required Guid OwnerMemberId { get; set; } + + /// Navigation to the uploader. + public FamilyMember? OwnerMember { get; set; } + + /// + /// Path relative to the storage root (e.g. wall/2026/04/<uuid>.jpg). + /// Unique across the table so an asset record always maps to exactly one file on disk. + /// + public required string RelativePath { get; set; } + + /// MIME type of the stored content (e.g. image/jpeg). + public required string ContentType { get; set; } + + /// Size of the stored content in bytes. + public required long SizeBytes { get; set; } + + /// Image width in pixels, if known. Null for non-image or undetected. + public int? Width { get; set; } + + /// Image height in pixels, if known. Null for non-image or undetected. + public int? Height { get; set; } +} diff --git a/src/FamilyNido.Domain/Health/HealthProfile.cs b/src/FamilyNido.Domain/Health/HealthProfile.cs new file mode 100644 index 0000000..7bc5c53 --- /dev/null +++ b/src/FamilyNido.Domain/Health/HealthProfile.cs @@ -0,0 +1,37 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Health; + +/// +/// Lightweight medical "card" for a single : blood +/// group, free-text allergies, chronic conditions and miscellaneous notes. +/// One row per member at most โ€” the profile is created lazily the first time +/// somebody saves it. +/// +/// +/// FamilyNido is not a clinical record system: this profile is a memory aid for +/// the household (e.g. handing the right info to a babysitter or to the GP). +/// Sensitive enough to keep the module gated to Admin/Adult +/// roles, but never validated against any medical taxonomy. +/// +public sealed class HealthProfile : AuditableEntity +{ + /// Member this profile belongs to. PK of the row through unique index. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the owning . + public FamilyMember? FamilyMember { get; set; } + + /// Blood group as a short string ("A+", "O-", "AB+", โ€ฆ). Null when unknown. + public string? BloodType { get; set; } + + /// Free-text list of allergies. Multi-line is allowed; the UI splits on newlines. + public string? Allergies { get; set; } + + /// Free-text list of chronic conditions (asma, diabetes, โ€ฆ). + public string? ChronicConditions { get; set; } + + /// Free-text general notes shown at the bottom of the card. + public string? Notes { get; set; } +} diff --git a/src/FamilyNido.Domain/Health/Medication.cs b/src/FamilyNido.Domain/Health/Medication.cs new file mode 100644 index 0000000..5cc64e9 --- /dev/null +++ b/src/FamilyNido.Domain/Health/Medication.cs @@ -0,0 +1,38 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Health; + +/// +/// Active or past medication taken by a . Stores +/// only what a caretaker needs to know at a glance โ€” name, dose, frequency, +/// start/end and free-form instructions. The module does not orchestrate +/// reminders directly; that's the role of the household-tasks module +/// (a "give Bob the antibiotic" recurring task references this row by name). +/// +public sealed class Medication : AuditableEntity +{ + /// Owning family member. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the owning . + public FamilyMember? FamilyMember { get; set; } + + /// Trade or generic name of the medication. + public required string Name { get; set; } + + /// Free-text dose ("5 ml", "1 comprimido"โ€ฆ). Null when not applicable. + public string? Dose { get; set; } + + /// Free-text frequency ("cada 8 h", "una vez al dรญa"โ€ฆ). + public string? Frequency { get; set; } + + /// Start date of the treatment. + public required DateOnly StartDate { get; set; } + + /// End date when the treatment is finite (null = ongoing). + public DateOnly? EndDate { get; set; } + + /// Free-text instructions ("con el desayuno", "no mezclar con leche"โ€ฆ). + public string? Instructions { get; set; } +} diff --git a/src/FamilyNido.Domain/Health/Vaccination.cs b/src/FamilyNido.Domain/Health/Vaccination.cs new file mode 100644 index 0000000..30714c1 --- /dev/null +++ b/src/FamilyNido.Domain/Health/Vaccination.cs @@ -0,0 +1,30 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Health; + +/// +/// One vaccination event recorded for a . Tracks the +/// administered dose plus, optionally, the date of the next required dose so +/// the dashboard and digest can surface it as a reminder. +/// +public sealed class Vaccination : AuditableEntity +{ + /// Owning family member. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the owning . + public FamilyMember? FamilyMember { get; set; } + + /// Vaccine name as recorded in the cartilla ("Triple vรญrica", "Tรฉtanos", โ€ฆ). + public required string Name { get; set; } + + /// Date the dose was administered (in the family's local calendar). + public required DateOnly Date { get; set; } + + /// Optional date of the next required dose. Null when one-shot or unknown. + public DateOnly? NextDueDate { get; set; } + + /// Optional free-text notes (lot number, reaction, doctor, โ€ฆ). + public string? Notes { get; set; } +} diff --git a/src/FamilyNido.Domain/HouseholdTasks/DayOfWeekMask.cs b/src/FamilyNido.Domain/HouseholdTasks/DayOfWeekMask.cs new file mode 100644 index 0000000..04746f4 --- /dev/null +++ b/src/FamilyNido.Domain/HouseholdTasks/DayOfWeekMask.cs @@ -0,0 +1,43 @@ +namespace FamilyNido.Domain.HouseholdTasks; + +/// +/// ISO-8601 ordered flag set for tasks. Uses a bitmask +/// (persisted as smallint) so a weekly pattern like "Monday + Thursday" is a single +/// value. Monday is the first day of the week (coherent with ES and most of the EU). +/// +[Flags] +public enum DayOfWeekMask : short +{ + /// No days selected. Equivalent to "never occurs" when Recurrence == Weekly. + None = 0, + + /// Lunes. + Monday = 1, + + /// Martes. + Tuesday = 1 << 1, + + /// Miรฉrcoles. + Wednesday = 1 << 2, + + /// Jueves. + Thursday = 1 << 3, + + /// Viernes. + Friday = 1 << 4, + + /// Sรกbado. + Saturday = 1 << 5, + + /// Domingo. + Sunday = 1 << 6, + + /// Lunes a viernes. + Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday, + + /// Sรกbado y domingo. + Weekend = Saturday | Sunday, + + /// Todos los dรญas de la semana. + All = Weekdays | Weekend, +} diff --git a/src/FamilyNido.Domain/HouseholdTasks/HouseholdTask.cs b/src/FamilyNido.Domain/HouseholdTasks/HouseholdTask.cs new file mode 100644 index 0000000..d7802d6 --- /dev/null +++ b/src/FamilyNido.Domain/HouseholdTasks/HouseholdTask.cs @@ -0,0 +1,248 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.HouseholdTasks; + +/// +/// Shared household chore (fregar el baรฑo, sacar la basura, comprar pan). Owns its own +/// recurrence motor โ€” deliberately simpler than iCalendar RRULE โ€” and its state is tracked +/// per-occurrence via , keeping a full audit of who did what. +/// +public sealed class HouseholdTask : AuditableEntity +{ + /// Family this task belongs to. + public required Guid FamilyId { get; set; } + + /// Navigation to the owning . + public Family? Family { get; set; } + + /// Short title shown in the list ("Fregar baรฑo"). + public required string Title { get; set; } + + /// Optional longer description or notes. + public string? Description { get; set; } + + /// + /// Free-form category label ("General", "Cocina", "Baรฑo", "Jardรญn"โ€ฆ). Kept as a string + /// rather than an enum so families can evolve their taxonomy without schema changes. + /// + public string Category { get; set; } = "General"; + + /// Recurrence pattern. Determines which of / applies. + public RecurrenceMode Recurrence { get; set; } = RecurrenceMode.None; + + /// + /// Bitmask of weekdays when is . + /// Ignored for other modes. + /// + public DayOfWeekMask? WeeklyDays { get; set; } + + /// + /// Day of the month (1..31) when is , + /// with the special value -1 meaning "last day of the month". A value above the last + /// day of a given month collapses to that month's last day (so 31 on February becomes 28/29). + /// + public int? MonthlyDay { get; set; } + + /// + /// Informative time-of-day target ("por la maรฑana", "a las 20:00"). Not used by the motor in v1; + /// reserved for v2 push reminders (RF-TASK-008). + /// + public TimeOnly? TimeOfDay { get; set; } + + /// + /// Pivot date: the task does not generate occurrences before this date, regardless of pattern. + /// For non-recurring tasks without a , the single occurrence falls on this date. + /// + public required DateOnly StartDate { get; set; } + + /// + /// Target date for single-shot tasks (). Null for recurring tasks + /// or for undated one-shots (the latter fall back to for occurrence placement). + /// + public DateOnly? DueDate { get; set; } + + /// + /// Reward in "puntos" earned by the member that marks an occurrence done. Range 1..10 + /// (validated at the application layer). Drives the family scoreboard / gamification โ€” + /// totals are computed at query time from joined back to this + /// field, so editing the reward also reshapes the historical score (mutable history is + /// the deliberate trade-off). + /// + public int Points { get; set; } = 5; + + /// + /// Soft-delete flag. Archived tasks disappear from the default views but preserve their + /// history and can be restored. + /// + public bool IsArchived { get; set; } + + /// + /// "Floating" task โ€” has no fixed date and stays pending in the Today view every day + /// until the first is recorded. Mutually exclusive with + /// recurrence ( must be ) and + /// with a target date ( must be null) โ€” those are validated at the + /// application layer when the field is set. Used for "encargar el jarabe en la + /// farmacia" type chores: important to remember every day, not tied to one in particular. + /// + public bool IsFloating { get; set; } + + /// Member who created the task. Only that member (or an admin) may delete it. + public required Guid CreatedByMemberId { get; set; } + + /// Navigation to the creator. + public FamilyMember? CreatedByMember { get; set; } + + /// + /// The single member that owns the execution of this task. Optional โ€” a task with + /// no responsible is "open" and any family member can mark it as done. Distinct + /// from : the responsible *does* the chore, while + /// related members are the ones the chore is *about* (e.g. "go pick up the kids": + /// responsible = Dan, related = Bob + Charlie). + /// + public Guid? ResponsibleMemberId { get; set; } + + /// Navigation to the responsible member. + public FamilyMember? ResponsibleMember { get; set; } + + /// + /// Members the task concerns or affects. Zero, one or many. Used to surface the + /// task on the per-member dashboard even when the member is not the one doing it. + /// + public ICollection RelatedMembers { get; set; } = []; + + /// Per-occurrence completion records. + public ICollection Completions { get; set; } = []; + + /// + /// Pure enumeration of the dates on which this task is scheduled to occur within + /// .. inclusive. Archived tasks yield no + /// occurrences. The effective start is clamped to . + /// + /// Lower bound of the window (inclusive). + /// Upper bound of the window (inclusive). + /// Ordered sequence of occurrence dates. + public IEnumerable EnumerateOccurrences(DateOnly from, DateOnly to) + { + if (to < from || IsArchived) + { + yield break; + } + + // Floating tasks have no scheduled date โ€” they're emitted directly by + // the application handlers on the "today" anchor when no completion + // exists yet, so this method intentionally yields nothing for them. + if (IsFloating) + { + yield break; + } + + var start = from > StartDate ? from : StartDate; + if (to < start) + { + yield break; + } + + switch (Recurrence) + { + case RecurrenceMode.None: + // Two flavours for non-recurring tasks: + // - Single-shot: DueDate equals StartDate, or DueDate is null. + // One occurrence on DueDate (or StartDate when undated). + // - "Deadline" task: DueDate > StartDate. The task appears every + // day in [StartDate, DueDate] so the family is reminded each + // day until it's completed. The application layer treats + // these like floating tasks once a completion exists (any + // completion at all wipes the task from future days), see + // GetTodayTasks.LoadRangeAsync. + if (DueDate is { } due && due > StartDate) + { + var rangeStart = start > StartDate ? start : StartDate; + var rangeEnd = to < due ? to : due; + for (var d = rangeStart; d <= rangeEnd; d = d.AddDays(1)) + { + yield return d; + } + } + else + { + var single = DueDate ?? StartDate; + if (single >= start && single <= to) + { + yield return single; + } + } + break; + + case RecurrenceMode.Daily: + for (var d = start; d <= to; d = d.AddDays(1)) + { + yield return d; + } + break; + + case RecurrenceMode.Weekly: + if (WeeklyDays is null or DayOfWeekMask.None) + { + yield break; + } + for (var d = start; d <= to; d = d.AddDays(1)) + { + if ((WeeklyDays.Value & ToMask(d.DayOfWeek)) != DayOfWeekMask.None) + { + yield return d; + } + } + break; + + case RecurrenceMode.Monthly: + if (MonthlyDay is null) + { + yield break; + } + // Walk month-by-month to avoid iterating days that never match. + var cursor = new DateOnly(start.Year, start.Month, 1); + while (cursor <= to) + { + var daysInMonth = DateTime.DaysInMonth(cursor.Year, cursor.Month); + var target = MonthlyDay.Value == -1 + ? daysInMonth + : Math.Min(MonthlyDay.Value, daysInMonth); + var occurrence = new DateOnly(cursor.Year, cursor.Month, target); + if (occurrence >= start && occurrence <= to) + { + yield return occurrence; + } + cursor = cursor.AddMonths(1); + } + break; + } + } + + /// + /// True when the task is scheduled to occur on . Convenience wrapper + /// around used by handlers to validate that a given + /// occurrence date is actually one of the task's occurrences. + /// + public bool HasOccurrenceOn(DateOnly date) => EnumerateOccurrences(date, date).Any(); + + /// + /// True when this is a "deadline" task โ€” non-recurring with strictly + /// after . These appear every day in the range until a single + /// completion graduates them like a floating task. + /// + public bool IsDeadlineRange => + Recurrence == RecurrenceMode.None && DueDate is { } due && due > StartDate; + + private static DayOfWeekMask ToMask(DayOfWeek dow) => dow switch + { + DayOfWeek.Monday => DayOfWeekMask.Monday, + DayOfWeek.Tuesday => DayOfWeekMask.Tuesday, + DayOfWeek.Wednesday => DayOfWeekMask.Wednesday, + DayOfWeek.Thursday => DayOfWeekMask.Thursday, + DayOfWeek.Friday => DayOfWeekMask.Friday, + DayOfWeek.Saturday => DayOfWeekMask.Saturday, + DayOfWeek.Sunday => DayOfWeekMask.Sunday, + _ => DayOfWeekMask.None, + }; +} diff --git a/src/FamilyNido.Domain/HouseholdTasks/RecurrenceMode.cs b/src/FamilyNido.Domain/HouseholdTasks/RecurrenceMode.cs new file mode 100644 index 0000000..7f14a60 --- /dev/null +++ b/src/FamilyNido.Domain/HouseholdTasks/RecurrenceMode.cs @@ -0,0 +1,22 @@ +namespace FamilyNido.Domain.HouseholdTasks; + +/// +/// Recurrence pattern for a . Deliberately limited to the +/// handful of patterns that cover household use cases ("fregar los lunes", "limpiar el +/// primer dรญa del mes"); complex rules (e.g. "el tercer viernes") are out of scope โ€” +/// the module owns its own motor, independent of the calendar's iCalendar RRULE. +/// +public enum RecurrenceMode +{ + /// Single-occurrence task (with optional due date). + None = 0, + + /// Repeats every day within the active window. + Daily = 1, + + /// Repeats on selected days of the week โ€” see . + Weekly = 2, + + /// Repeats on a specific day of each month โ€” see . + Monthly = 3, +} diff --git a/src/FamilyNido.Domain/HouseholdTasks/TaskCompletion.cs b/src/FamilyNido.Domain/HouseholdTasks/TaskCompletion.cs new file mode 100644 index 0000000..cc27ae8 --- /dev/null +++ b/src/FamilyNido.Domain/HouseholdTasks/TaskCompletion.cs @@ -0,0 +1,44 @@ +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.HouseholdTasks; + +/// +/// A single "done" marker for one specific occurrence of a . +/// The unique constraint (TaskId, OccurrenceDate) guarantees that marking the same +/// occurrence twice is idempotent. Preserving per-occurrence rows keeps an audit trail +/// (who did what, when) and feeds future statistics โ€” streaks, member contributions. +/// +public sealed class TaskCompletion +{ + /// Surrogate PK, UUIDv7 for time-ordered inserts. + public Guid Id { get; set; } = Guid.CreateVersion7(); + + /// Owning task. + public required Guid TaskId { get; set; } + + /// Navigation to the owning . + public HouseholdTask? Task { get; set; } + + /// + /// Date the completion refers to. For recurring tasks this is the date of the specific + /// occurrence being marked; for single-shot tasks it is the task's + /// (or as a stand-in when there is no due date โ€” see + /// ). + /// + public required DateOnly OccurrenceDate { get; set; } + + /// + /// Member who marked the occurrence as done. Null means "anonymous" (e.g. the member + /// was deleted after completing); we use SET NULL on delete to preserve history. + /// + public Guid? CompletedByMemberId { get; set; } + + /// Navigation to the completing . + public FamilyMember? CompletedBy { get; set; } + + /// UTC instant the occurrence was marked. + public required DateTimeOffset CompletedAt { get; set; } + + /// Optional free-text note left by the person marking the task (e.g. "hecho antes de comer"). + public string? Note { get; set; } +} diff --git a/src/FamilyNido.Domain/Identity/IdentityProvider.cs b/src/FamilyNido.Domain/Identity/IdentityProvider.cs new file mode 100644 index 0000000..ac078f9 --- /dev/null +++ b/src/FamilyNido.Domain/Identity/IdentityProvider.cs @@ -0,0 +1,19 @@ +namespace FamilyNido.Domain.Identity; + +/// +/// How a proves the caller is a given . +/// +/// +/// Each user can carry several credentials simultaneously: e.g. one +/// credential bound to PocketID and one +/// credential with a stored password hash. The user picks how to log in each +/// time; both routes resolve to the same id. +/// +public enum IdentityProvider +{ + /// OpenID Connect identity (e.g. PocketID). Stores the OIDC sub. + Oidc = 0, + + /// Local email + password identity. Stores a PBKDF2 password hash. + Local = 1, +} diff --git a/src/FamilyNido.Domain/Identity/Invitation.cs b/src/FamilyNido.Domain/Identity/Invitation.cs new file mode 100644 index 0000000..01a054f --- /dev/null +++ b/src/FamilyNido.Domain/Identity/Invitation.cs @@ -0,0 +1,51 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Identity; + +/// +/// One-time, time-limited token an admin emits so a person can claim a +/// . The recipient may accept the invitation +/// indistinctly via OIDC (existing PocketID account) or by setting a local +/// password โ€” both paths bind the same to a +/// and consume the row. +/// +/// +/// Only the SHA-256 of the token is persisted (); the +/// raw token lives only in the email sent to the recipient and in the URL +/// the recipient clicks. Acceptance is performed atomically with a +/// conditional UPDATE so two concurrent clicks cannot consume the same +/// invitation twice. +/// +public sealed class Invitation : AuditableEntity +{ + /// Family this invitation belongs to. + public required Guid FamilyId { get; set; } + + /// Member that will be linked to the accepting user. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the family member. + public FamilyMember? FamilyMember { get; set; } + + /// Email the invitation was sent to (normalized: trimmed + lowercase). + public required string Email { get; set; } + + /// Role the accepting user receives. Validator forbids . + public required FamilyRole RoleOnAccept { get; set; } + + /// SHA-256 of the raw token. Indexed UNIQUE. + public required byte[] TokenHash { get; set; } + + /// UTC instant after which the token can no longer be redeemed. + public required DateTimeOffset ExpiresAt { get; set; } + + /// UTC instant the token was redeemed. Null while pending. + public DateTimeOffset? ConsumedAt { get; set; } + + /// User that redeemed the token. Null while pending. + public Guid? ConsumedByUserId { get; set; } + + /// UTC instant the token was revoked by an admin. Null when active. + public DateTimeOffset? RevokedAt { get; set; } +} diff --git a/src/FamilyNido.Domain/Identity/User.cs b/src/FamilyNido.Domain/Identity/User.cs new file mode 100644 index 0000000..063feb7 --- /dev/null +++ b/src/FamilyNido.Domain/Identity/User.cs @@ -0,0 +1,51 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Identity; + +/// +/// Authenticable account in FamilyNido. Identity is provider-agnostic: a user +/// proves who they are via one or more rows +/// (OIDC subject, local password hash, โ€ฆ). The internal id never changes +/// across credential rotations. +/// +/// +/// Linking a user to a is an explicit administrative +/// action (RF-AUTH-003): orphan users (no member) are rejected at the API +/// surface until an admin links them, typically by accepting an +/// . +/// +public sealed class User : AuditableEntity +{ + /// Email claim at the time of login. Used as the lookup key for local login. + public required string Email { get; set; } + + /// Display name claim at the time of login (fallback to the email local-part). + public required string DisplayName { get; set; } + + /// Authorization role used by ASP.NET Core policies. + public FamilyRole Role { get; set; } = FamilyRole.Adult; + + /// Timestamp of the most recent successful login (any credential). + public DateTimeOffset? LastLoginAt { get; set; } + + /// + /// Watermark used by the wall unread-counter (RF-WALL-010). Messages created after + /// this instant count as unread for this user until they open the wall again. + /// + public DateTimeOffset? LastWallReadAt { get; set; } + + /// + /// IETF BCP-47 tag picked by the user for the FamilyNido UI. Used server-side + /// to render digest emails, mention notifications, and integration-generated + /// task titles in the right language. Defaults to es-ES โ€” the + /// product's source locale. + /// + public string PreferredLanguage { get; set; } = "es-ES"; + + /// Navigation back to the linked family member, if any. + public FamilyMember? FamilyMember { get; set; } + + /// Authentication credentials owned by this user (1..N). + public ICollection Credentials { get; set; } = new List(); +} diff --git a/src/FamilyNido.Domain/Identity/UserCredential.cs b/src/FamilyNido.Domain/Identity/UserCredential.cs new file mode 100644 index 0000000..acc33eb --- /dev/null +++ b/src/FamilyNido.Domain/Identity/UserCredential.cs @@ -0,0 +1,37 @@ +using FamilyNido.Domain.Common; + +namespace FamilyNido.Domain.Identity; + +/// +/// A single way to authenticate as a given . A user may have +/// many: each row binds one provider (OIDC subject or local password hash) to +/// the same internal . +/// +/// +/// Coherence is enforced both at the application layer and via a database +/// CHECK constraint: +/// +/// rows have set and null. +/// rows have set and null. +/// +/// +public sealed class UserCredential : AuditableEntity +{ + /// Owning user. + public required Guid UserId { get; set; } + + /// Navigation back to the owner. + public User? User { get; set; } + + /// How the caller proves their identity. + public required IdentityProvider Provider { get; set; } + + /// OIDC sub claim (issued by the provider). Null for local credentials. + public string? ProviderKey { get; set; } + + /// PBKDF2 password hash (ASP.NET Core PasswordHasher v3 format). Null for OIDC. + public string? PasswordHash { get; set; } + + /// UTC timestamp of the most recent successful authentication using this credential. + public DateTimeOffset? LastUsedAt { get; set; } +} diff --git a/src/FamilyNido.Domain/Integrations/IntegrationApiKey.cs b/src/FamilyNido.Domain/Integrations/IntegrationApiKey.cs new file mode 100644 index 0000000..afdd39b --- /dev/null +++ b/src/FamilyNido.Domain/Integrations/IntegrationApiKey.cs @@ -0,0 +1,56 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Integrations; + +/// +/// Long-lived shared secret used by an external integration ( +/// shortcuts, scripts) to call FamilyNido endpoints under +/// /api/integrations/**. The plaintext token is shown once at creation +/// and only its SHA-256 digest is persisted, so a stolen database row cannot +/// be replayed against the API. +/// +/// +/// Scope is limited to /api/integrations/**: a leaked token cannot read +/// the wall, the calendar, or anything else โ€” at worst it can create the kind +/// of items the integration endpoints already accept (e.g. household tasks). +/// +public sealed class IntegrationApiKey : AuditableEntity +{ + /// Family the token belongs to. Drives data isolation. + public required Guid FamilyId { get; set; } + + /// Navigation to the owning . + public Family? Family { get; set; } + + /// + /// Member that created the token; recorded as the author of any artefact + /// produced through it (e.g. HouseholdTask.CreatedByMemberId). + /// + public required Guid AuthorMemberId { get; set; } + + /// Navigation to the author member. + public FamilyMember? AuthorMember { get; set; } + + /// Free-form human label ("my automation", "iPhone shortcuts"). + public required string Name { get; set; } + + /// Hex-encoded SHA-256 of the plaintext token. Lookups go through this. + public required string TokenHash { get; set; } + + /// + /// Public prefix shown in the UI so users can tell tokens apart without + /// exposing the secret. Convention: first 12 chars of the plaintext, e.g. + /// bxn_a1b2c3d4. + /// + public required string Prefix { get; set; } + + /// UTC instant of the most recent successful authentication. Null until first use. + public DateTimeOffset? LastUsedAt { get; set; } + + /// UTC instant when the token was revoked. Null while active. Soft-delete: row stays for audit. + public DateTimeOffset? RevokedAt { get; set; } + + /// True when the token can still authenticate. + public bool IsActive => RevokedAt is null; +} diff --git a/src/FamilyNido.Domain/Meals/MealCourse.cs b/src/FamilyNido.Domain/Meals/MealCourse.cs new file mode 100644 index 0000000..4765d3c --- /dev/null +++ b/src/FamilyNido.Domain/Meals/MealCourse.cs @@ -0,0 +1,15 @@ +namespace FamilyNido.Domain.Meals; + +/// +/// Course within a meal slot. Spanish meals typically have a "primer plato" +/// (first course โ€” soup, salad, pastaโ€ฆ) and a "segundo plato" (main โ€” meat, +/// fish, eggsโ€ฆ); either of them may be skipped on a casual day. +/// +public enum MealCourse +{ + /// Primer plato โ€” typically a starter, soup or pasta. + First = 0, + + /// Segundo plato โ€” typically the main dish. + Second = 1, +} diff --git a/src/FamilyNido.Domain/Meals/MealPlanSlot.cs b/src/FamilyNido.Domain/Meals/MealPlanSlot.cs new file mode 100644 index 0000000..ca0f403 --- /dev/null +++ b/src/FamilyNido.Domain/Meals/MealPlanSlot.cs @@ -0,0 +1,38 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Meals; + +/// +/// One cell in the weekly meal planner. Holds the free-text name of what the +/// family plans (or planned) to eat at a given and +/// . Each row carries up to two courses (primer and segundo +/// plato); at least one must be non-empty โ€” a row with both courses null is +/// deleted by the application layer instead of being kept around as a tombstone. +/// +public sealed class MealPlanSlot : AuditableEntity +{ + /// Family this slot belongs to (authorization boundary). + public required Guid FamilyId { get; set; } + + /// Navigation to the owning . + public Family? Family { get; set; } + + /// Date of the slot in the family's local calendar. + public required DateOnly Date { get; set; } + + /// Which slot of the day this row represents. + public required MealSlot Slot { get; set; } + + /// + /// Primer plato โ€” free-text, 1..120 chars when present. Null when the + /// family decided to skip a starter and only register a main dish. + /// + public string? FirstCourse { get; set; } + + /// + /// Segundo plato โ€” free-text, 1..120 chars when present. Null when only + /// the starter is registered. + /// + public string? SecondCourse { get; set; } +} diff --git a/src/FamilyNido.Domain/Meals/MealSlot.cs b/src/FamilyNido.Domain/Meals/MealSlot.cs new file mode 100644 index 0000000..ea47a1c --- /dev/null +++ b/src/FamilyNido.Domain/Meals/MealSlot.cs @@ -0,0 +1,16 @@ +namespace FamilyNido.Domain.Meals; + +/// +/// Slot of the day a meal entry belongs to. v1 only models the two slots that +/// the family actually plans (comida y cena); breakfast/snack would land here +/// later as additional cases without breaking persistence (the column is a +/// readable string). +/// +public enum MealSlot +{ + /// "Comida" โ€” midday main meal in the Spanish family schedule. + Lunch = 0, + + /// "Cena" โ€” evening main meal. + Dinner = 1, +} diff --git a/src/FamilyNido.Domain/Notifications/EmailDigestRun.cs b/src/FamilyNido.Domain/Notifications/EmailDigestRun.cs new file mode 100644 index 0000000..405943b --- /dev/null +++ b/src/FamilyNido.Domain/Notifications/EmailDigestRun.cs @@ -0,0 +1,25 @@ +using FamilyNido.Domain.Identity; + +namespace FamilyNido.Domain.Notifications; + +/// +/// Audit row marking that today's digest has been processed for a given user. +/// The composite primary key (UserId, LocalDate) doubles as a uniqueness +/// guard so the background scanner can never double-send: inserting the row +/// before queueing the email means a second pass within the same local day is +/// short-circuited by EF. +/// +public sealed class EmailDigestRun +{ + /// User the digest was processed for. + public required Guid UserId { get; set; } + + /// Navigation to the owning . + public User? User { get; set; } + + /// Date in the family's local timezone โ€” the natural deduplication key. + public required DateOnly LocalDate { get; set; } + + /// UTC instant the row was inserted (and the email queued, if applicable). + public DateTimeOffset SentAt { get; set; } +} diff --git a/src/FamilyNido.Domain/Notifications/UserDashboardPreferences.cs b/src/FamilyNido.Domain/Notifications/UserDashboardPreferences.cs new file mode 100644 index 0000000..628fce2 --- /dev/null +++ b/src/FamilyNido.Domain/Notifications/UserDashboardPreferences.cs @@ -0,0 +1,26 @@ +using FamilyNido.Domain.Identity; + +namespace FamilyNido.Domain.Notifications; + +/// +/// Per-user customisation of the dashboard layout: which widgets are visible +/// and in what order. Persisted as a single JSON column so adding new widget +/// kinds in the future doesn't require a schema change. +/// +/// +/// The row is created lazily on first save. When absent, the API surfaces the +/// default order with every widget visible โ€” that's why all properties on the +/// DTO returned by GET /api/dashboard/preferences are populated even +/// for users that never opened the settings screen. +/// +public sealed class UserDashboardPreferences +{ + /// Owning user. Doubles as the primary key โ€” at most one row per user. + public required Guid UserId { get; set; } + + /// Navigation to the owning . + public User? User { get; set; } + + /// JSON string with the ordered list of {id,visible} objects. + public string WidgetsJson { get; set; } = string.Empty; +} diff --git a/src/FamilyNido.Domain/Notifications/UserNotificationPreferences.cs b/src/FamilyNido.Domain/Notifications/UserNotificationPreferences.cs new file mode 100644 index 0000000..05f6559 --- /dev/null +++ b/src/FamilyNido.Domain/Notifications/UserNotificationPreferences.cs @@ -0,0 +1,34 @@ +using FamilyNido.Domain.Identity; + +namespace FamilyNido.Domain.Notifications; + +/// +/// Per-user toggles for outbound notifications. Defaults to "everything on" so +/// new users automatically get useful emails until they opt out. The row is +/// lazily upserted the first time the user opens the preferences screen. +/// +/// +/// We keep the model intentionally narrow (one boolean per channel) so the +/// settings screen stays a checklist. Adding push or per-event granularity +/// later means new bool columns, not a new schema. +/// +public sealed class UserNotificationPreferences +{ + /// Owning user. Doubles as the primary key โ€” at most one row per user. + public required Guid UserId { get; set; } + + /// Navigation to the owning . + public User? User { get; set; } + + /// Master switch. When false the dispatcher skips the user entirely. + public bool EmailEnabled { get; set; } = true; + + /// Receive the morning digest with today's tasks/events/birthdays. + public bool DigestEnabled { get; set; } = true; + + /// Receive an email when assigned as the responsible of a task. + public bool TaskAssignedEnabled { get; set; } = true; + + /// Receive an email when mentioned via @ on the wall. + public bool WallMentionEnabled { get; set; } = true; +} diff --git a/src/FamilyNido.Domain/School/Extracurricular.cs b/src/FamilyNido.Domain/School/Extracurricular.cs new file mode 100644 index 0000000..b16bcd0 --- /dev/null +++ b/src/FamilyNido.Domain/School/Extracurricular.cs @@ -0,0 +1,76 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; +using FamilyNido.Domain.HouseholdTasks; + +namespace FamilyNido.Domain.School; + +/// +/// A recurring after-school activity (inglรฉs, ballet, fรบtbolโ€ฆ) for one +/// . Instances are derived from the +/// bitmask between and +/// ; specific dates can override or cancel via +/// . +/// +/// +/// Drop-off and pick-up caretakers are references โ€” +/// grandparents and other non-authenticated caretakers fit naturally as +/// . Both fields are optional because some +/// activities are taken to and from by the same person, or none at all +/// (the kid walks back). +/// +public sealed class Extracurricular : AuditableEntity +{ + /// Family this activity belongs to (denormalised for fast queries). + public required Guid FamilyId { get; set; } + + /// Kid attending the activity. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the kid. + public FamilyMember? FamilyMember { get; set; } + + /// Activity name shown in the lists ("Inglรฉs", "Ballet"). + public required string Name { get; set; } + + /// Optional location ("Centro La Salle", "Polideportivo de Etxarri"โ€ฆ). + public string? Location { get; set; } + + /// Optional contact phone for the activity (academy/coach/centre). + public string? ContactPhone { get; set; } + + /// Bitmask of weekdays the activity occurs (reuses the household-task mask). + public required DayOfWeekMask WeeklyDays { get; set; } + + /// Local start time of each session. + public required TimeOnly StartTime { get; set; } + + /// Local end time of each session. + public required TimeOnly EndTime { get; set; } + + /// First date the activity runs (clamps the recurrence floor). + public required DateOnly StartDate { get; set; } + + /// Last date the activity runs. Null when open-ended. + public DateOnly? EndDate { get; set; } + + /// Default caretaker who takes the kid to the activity. Null when N/A. + public Guid? DefaultDropoffMemberId { get; set; } + + /// Navigation to the drop-off caretaker. + public FamilyMember? DefaultDropoffMember { get; set; } + + /// Default caretaker who picks the kid up after the activity. Null when N/A. + public Guid? DefaultPickupMemberId { get; set; } + + /// Navigation to the pick-up caretaker. + public FamilyMember? DefaultPickupMember { get; set; } + + /// Free-form notes ("traer mochila", "alergia al lรกtex"โ€ฆ). + public string? Notes { get; set; } + + /// Soft archive flag โ€” past courses are preserved for history. + public bool IsArchived { get; set; } + + /// Per-date overrides and cancellations. + public ICollection Exceptions { get; set; } = []; +} diff --git a/src/FamilyNido.Domain/School/ExtracurricularException.cs b/src/FamilyNido.Domain/School/ExtracurricularException.cs new file mode 100644 index 0000000..500f036 --- /dev/null +++ b/src/FamilyNido.Domain/School/ExtracurricularException.cs @@ -0,0 +1,40 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.School; + +/// +/// Per-date override of an . Either flags the +/// session as cancelled ( true) โ€” useful when the +/// course skips a day for a specific reason that doesn't match a global +/// holiday โ€” or reassigns the drop-off / pick-up caretaker for that day. +/// +public sealed class ExtracurricularException : AuditableEntity +{ + /// Owning extracurricular activity. + public required Guid ExtracurricularId { get; set; } + + /// Navigation to the owning . + public Extracurricular? Extracurricular { get; set; } + + /// Date the override applies to. + public required DateOnly Date { get; set; } + + /// True when the session is cancelled that day. + public bool IsCancelled { get; set; } + + /// Drop-off caretaker that day, or null when unchanged / cancelled. + public Guid? DropoffMemberId { get; set; } + + /// Navigation to the drop-off override caretaker. + public FamilyMember? DropoffMember { get; set; } + + /// Pick-up caretaker that day, or null when unchanged / cancelled. + public Guid? PickupMemberId { get; set; } + + /// Navigation to the pick-up override caretaker. + public FamilyMember? PickupMember { get; set; } + + /// Optional context note. + public string? Notes { get; set; } +} diff --git a/src/FamilyNido.Domain/School/SchoolDayException.cs b/src/FamilyNido.Domain/School/SchoolDayException.cs new file mode 100644 index 0000000..9c72f27 --- /dev/null +++ b/src/FamilyNido.Domain/School/SchoolDayException.cs @@ -0,0 +1,56 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.School; + +/// +/// Per-date override of the kid's school commute. Either flags the day as +/// cancelled ( true) or reassigns drop-off and / or +/// pick-up caretakers for that day. Absence of a row means the weekly +/// applies as-is. +/// +public sealed class SchoolDayException : AuditableEntity +{ + /// Family this exception belongs to (denormalised for fast queries). + public required Guid FamilyId { get; set; } + + /// Kid the override applies to. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the kid. + public FamilyMember? FamilyMember { get; set; } + + /// Date the override applies to. + public required DateOnly Date { get; set; } + + /// True when there's no commute that day (e.g. teacher strike, day off). + public bool IsCancelled { get; set; } + + /// Drop-off override that day, when reassigned. Null when cancelled or unchanged. + public Guid? DropoffMemberId { get; set; } + + /// Navigation to the drop-off override caretaker. + public FamilyMember? DropoffMember { get; set; } + + /// Pick-up override that day, when reassigned. Null when cancelled or unchanged. + public Guid? PickupMemberId { get; set; } + + /// Navigation to the pick-up override caretaker. + public FamilyMember? PickupMember { get; set; } + + /// + /// Optional override of the morning entry time for this single date. Null + /// falls back to the profile's . Set + /// when the centre announces a special schedule for the day. + /// + public TimeOnly? MorningTime { get; set; } + + /// + /// Optional override of the afternoon exit time for this single date. Null + /// falls back to the profile's . + /// + public TimeOnly? AfternoonTime { get; set; } + + /// Optional note for context ("le recoge el aitite porque tengo mรฉdico"). + public string? Notes { get; set; } +} diff --git a/src/FamilyNido.Domain/School/SchoolDaySchedule.cs b/src/FamilyNido.Domain/School/SchoolDaySchedule.cs new file mode 100644 index 0000000..465598d --- /dev/null +++ b/src/FamilyNido.Domain/School/SchoolDaySchedule.cs @@ -0,0 +1,47 @@ +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.School; + +/// +/// Weekly default for a kid's commute to / from school on a given weekday. +/// Replaces the bus-only BusPickupSchedule: each row carries an optional +/// drop-off caretaker (who takes the kid in the morning) and an optional +/// pick-up caretaker (who picks them up in the afternoon). +/// +/// +/// +/// Composite key (FamilyMemberId, DayOfWeek). Both caretaker fields are +/// nullable but at least one must be set โ€” that's enforced at the application +/// layer because the meaningful slot depends on the kid's +/// : a bus kid only fills the pickup, +/// a kid that walks fills both. +/// +/// +/// Caretakers are normal rows. Use +/// for grandparents (aitites) so they can be +/// referenced here without polluting the adult/child rosters elsewhere. +/// +/// +public sealed class SchoolDaySchedule +{ + /// Kid the schedule entry refers to. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the kid. + public FamilyMember? FamilyMember { get; set; } + + /// Weekday this entry applies to. + public required DayOfWeek DayOfWeek { get; set; } + + /// Caretaker that takes the kid to the centre in the morning, when applicable. + public Guid? DropoffMemberId { get; set; } + + /// Navigation to the drop-off caretaker. + public FamilyMember? DropoffMember { get; set; } + + /// Caretaker that picks the kid up in the afternoon, when applicable. + public Guid? PickupMemberId { get; set; } + + /// Navigation to the pick-up caretaker. + public FamilyMember? PickupMember { get; set; } +} diff --git a/src/FamilyNido.Domain/School/SchoolHoliday.cs b/src/FamilyNido.Domain/School/SchoolHoliday.cs new file mode 100644 index 0000000..0279664 --- /dev/null +++ b/src/FamilyNido.Domain/School/SchoolHoliday.cs @@ -0,0 +1,27 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.School; + +/// +/// Family-wide school holiday range. Cancels every bus pickup and every +/// extracurricular session whose date falls inside .. +/// (inclusive). One-day holidays are stored with the same start and end. +/// +public sealed class SchoolHoliday : AuditableEntity +{ + /// Family this holiday belongs to (authorisation boundary). + public required Guid FamilyId { get; set; } + + /// Navigation to the owning . + public Family? Family { get; set; } + + /// Inclusive start date of the holiday range. + public required DateOnly StartDate { get; set; } + + /// Inclusive end date โ€” equal to for one-day holidays. + public required DateOnly EndDate { get; set; } + + /// Human label ("Vacaciones de Navidad", "Dรญa del Pilar"โ€ฆ). + public required string Label { get; set; } +} diff --git a/src/FamilyNido.Domain/School/SchoolProfile.cs b/src/FamilyNido.Domain/School/SchoolProfile.cs new file mode 100644 index 0000000..2700621 --- /dev/null +++ b/src/FamilyNido.Domain/School/SchoolProfile.cs @@ -0,0 +1,53 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.School; + +/// +/// Lightweight school "card" for a single : centro, +/// curso, tutor and miscellaneous notes plus the typical bus arrival time used +/// by the dashboard widget. One row per member at most โ€” the profile is +/// created lazily the first time it's saved. +/// +public sealed class SchoolProfile : AuditableEntity +{ + /// Member this profile belongs to. Unique-indexed below. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the owning . + public FamilyMember? FamilyMember { get; set; } + + /// Name of the school ("CEIP Las Acacias"), null when unknown. + public string? SchoolName { get; set; } + + /// Course / level ("3ยบ Primaria"), null when unknown. + public string? Grade { get; set; } + + /// Tutor name (one line). Null when unknown. + public string? Tutor { get; set; } + + /// + /// How the kid commutes to / from this centre. Defaults to + /// so a freshly created profile doesn't + /// commit to anything until the family chooses. + /// + public TransportMode TransportMode { get; set; } = TransportMode.None; + + /// + /// Typical local time the kid arrives at the centre in the morning. + /// Used by the dashboard / digest to render "lleva a las 09:00" hints. + /// Null when not relevant (e.g. kids that + /// only care about the afternoon pickup). + /// + public TimeOnly? MorningTime { get; set; } + + /// + /// Typical local time the kid is picked up in the afternoon. Replaces the + /// previous BusArrivalTime field โ€” kept the same column post-rename + /// so the existing data carries over. + /// + public TimeOnly? AfternoonTime { get; set; } + + /// Free-form notes (allergies known by school, special arrangements, โ€ฆ). + public string? Notes { get; set; } +} diff --git a/src/FamilyNido.Domain/School/TransportMode.cs b/src/FamilyNido.Domain/School/TransportMode.cs new file mode 100644 index 0000000..74b1083 --- /dev/null +++ b/src/FamilyNido.Domain/School/TransportMode.cs @@ -0,0 +1,22 @@ +namespace FamilyNido.Domain.School; + +/// +/// How a child gets to and from their school / daycare. Drives which slots of +/// are meaningful and what icon the dashboard +/// surfaces โ€” it is not the source of truth for who-does-what (that lives in +/// the schedule rows themselves). +/// +public enum TransportMode +{ + /// No managed transport โ€” older kids that walk by themselves. + None = 0, + + /// School bus: the child rides one way and a caretaker picks them up. + Bus = 1, + + /// Walked to and from the centre by a caretaker. + Walk = 2, + + /// Driven to and from the centre by a caretaker. + Car = 3, +} diff --git a/src/FamilyNido.Domain/Wall/WallComment.cs b/src/FamilyNido.Domain/Wall/WallComment.cs new file mode 100644 index 0000000..2c2d1d9 --- /dev/null +++ b/src/FamilyNido.Domain/Wall/WallComment.cs @@ -0,0 +1,33 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Wall; + +/// +/// A 1-level reply under a . Threading is intentionally flat: +/// the module is a family "pizarra", not Reddit โ€” deeper trees would obscure more than +/// they'd help. +/// +public sealed class WallComment : AuditableEntity +{ + /// Owning message. + public required Guid MessageId { get; set; } + + /// Navigation to the owning . + public WallMessage? Message { get; set; } + + /// Member who authored the comment. + public required Guid AuthorMemberId { get; set; } + + /// Navigation to the comment author. + public FamilyMember? AuthorMember { get; set; } + + /// Raw markdown source as typed by the user. + public required string Text { get; set; } + + /// Pre-rendered, sanitized HTML derived from . + public required string TextHtml { get; set; } + + /// Members referenced via @DisplayName in โ€” drives notifications. + public ICollection Mentions { get; set; } = []; +} diff --git a/src/FamilyNido.Domain/Wall/WallCommentMention.cs b/src/FamilyNido.Domain/Wall/WallCommentMention.cs new file mode 100644 index 0000000..1ee8c5b --- /dev/null +++ b/src/FamilyNido.Domain/Wall/WallCommentMention.cs @@ -0,0 +1,23 @@ +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Wall; + +/// +/// Join row linking a with a +/// referenced via @DisplayName. Mirror of +/// for thread replies. +/// +public sealed class WallCommentMention +{ + /// Mentioned-in comment. + public required Guid CommentId { get; set; } + + /// Navigation to the owning . + public WallComment? Comment { get; set; } + + /// Mentioned member. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the mentioned . + public FamilyMember? FamilyMember { get; set; } +} diff --git a/src/FamilyNido.Domain/Wall/WallMessage.cs b/src/FamilyNido.Domain/Wall/WallMessage.cs new file mode 100644 index 0000000..99e4e00 --- /dev/null +++ b/src/FamilyNido.Domain/Wall/WallMessage.cs @@ -0,0 +1,56 @@ +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; +using FamilyNido.Domain.Files; + +namespace FamilyNido.Domain.Wall; + +/// +/// A post on the family wall. Carries markdown source plus pre-rendered HTML +/// (sanitized at write time with Markdig) and optionally a single attached image. +/// Messages can be pinned, reacted to and commented on. Totally internal to the +/// family โ€” never shared outside. +/// +public sealed class WallMessage : AuditableEntity +{ + /// Family this message belongs to (authorization boundary). + public required Guid FamilyId { get; set; } + + /// Navigation to the owning . + public Family? Family { get; set; } + + /// Member who authored the message. + public required Guid AuthorMemberId { get; set; } + + /// Navigation to the author. + public FamilyMember? AuthorMember { get; set; } + + /// Raw markdown source as typed by the user. Preserved so edits work on the original. + public required string Text { get; set; } + + /// Pre-rendered, sanitized HTML derived from via Markdig. + public required string TextHtml { get; set; } + + /// + /// Optional image attached to the message. Nullable because most messages are text-only. + /// Deleting the file unsets this FK (SetNull) so the message is not lost with it. + /// + public Guid? ImageFileId { get; set; } + + /// Navigation to the attached . + public FileAsset? ImageFile { get; set; } + + /// When true, the message is shown in the pinned zone at the top of the wall. + public bool IsPinned { get; set; } + + /// UTC instant the message was pinned; null when not pinned. + public DateTimeOffset? PinnedAt { get; set; } + + /// 1-level thread of replies below the message (RF-WALL-005). + public ICollection Comments { get; set; } = []; + + /// Emoji reactions โ€” one row per (member, emoji) on this message. + public ICollection Reactions { get; set; } = []; + + /// Members referenced via @DisplayName in โ€” drives notifications. + public ICollection Mentions { get; set; } = []; +} diff --git a/src/FamilyNido.Domain/Wall/WallMessageMention.cs b/src/FamilyNido.Domain/Wall/WallMessageMention.cs new file mode 100644 index 0000000..cf9b327 --- /dev/null +++ b/src/FamilyNido.Domain/Wall/WallMessageMention.cs @@ -0,0 +1,23 @@ +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Wall; + +/// +/// Join row linking a with a +/// referenced via @DisplayName in the markdown source. Drives mention +/// notifications and visual highlights โ€” never carries content of its own. +/// +public sealed class WallMessageMention +{ + /// Mentioned-in message. + public required Guid MessageId { get; set; } + + /// Navigation to the owning . + public WallMessage? Message { get; set; } + + /// Mentioned member. + public required Guid FamilyMemberId { get; set; } + + /// Navigation to the mentioned . + public FamilyMember? FamilyMember { get; set; } +} diff --git a/src/FamilyNido.Domain/Wall/WallReaction.cs b/src/FamilyNido.Domain/Wall/WallReaction.cs new file mode 100644 index 0000000..dbb06a2 --- /dev/null +++ b/src/FamilyNido.Domain/Wall/WallReaction.cs @@ -0,0 +1,34 @@ +using FamilyNido.Domain.Families; + +namespace FamilyNido.Domain.Wall; + +/// +/// A single emoji reaction to a . The unique constraint +/// (MessageId, MemberId, Emoji) allows a member to reacted with several +/// distinct emojis to the same message but never the same emoji twice, so toggling +/// behaviour stays idempotent. Not an AuditableEntity: reactions are +/// throw-away records, we only keep the instant they were placed. +/// +public sealed class WallReaction +{ + /// Surrogate PK. UUIDv7 keeps inserts time-ordered. + public Guid Id { get; set; } = Guid.CreateVersion7(); + + /// Message this reaction belongs to. + public required Guid MessageId { get; set; } + + /// Navigation to the owning . + public WallMessage? Message { get; set; } + + /// Member who reacted. + public required Guid MemberId { get; set; } + + /// Navigation to the reacting . + public FamilyMember? Member { get; set; } + + /// The emoji as a string (e.g. "โค๏ธ", "๐ŸŽ‰"). + public required string Emoji { get; set; } + + /// UTC instant the reaction was placed. + public required DateTimeOffset ReactedAt { get; set; } +} diff --git a/src/FamilyNido.Persistence/ApplicationDbContext.cs b/src/FamilyNido.Persistence/ApplicationDbContext.cs new file mode 100644 index 0000000..2f15b6b --- /dev/null +++ b/src/FamilyNido.Persistence/ApplicationDbContext.cs @@ -0,0 +1,176 @@ +using FamilyNido.Domain.Agenda; +using FamilyNido.Domain.Calendar; +using FamilyNido.Domain.Common; +using FamilyNido.Domain.Families; +using FamilyNido.Domain.Files; +using FamilyNido.Domain.Health; +using FamilyNido.Domain.HouseholdTasks; +using FamilyNido.Domain.Identity; +using FamilyNido.Domain.Integrations; +using FamilyNido.Domain.Meals; +using FamilyNido.Domain.Notifications; +using FamilyNido.Domain.School; +using FamilyNido.Domain.Wall; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Persistence; + +/// +/// Primary EF Core context. Owns connection string, entity configurations, +/// and cross-cutting behaviors like audit-column population. +/// +public sealed class ApplicationDbContext : DbContext +{ + private readonly ICurrentActorProvider? _actorProvider; + private readonly TimeProvider _timeProvider; + + /// Primary constructor used by DI. + public ApplicationDbContext( + DbContextOptions options, + TimeProvider timeProvider, + ICurrentActorProvider? actorProvider = null) + : base(options) + { + _timeProvider = timeProvider; + _actorProvider = actorProvider; + } + + /// Design-time / migration-time constructor. + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + _timeProvider = TimeProvider.System; + } + + /// Families persisted in the instance (typically one). + public DbSet Families => Set(); + + /// Every person belonging to any family, authenticable or not. + public DbSet FamilyMembers => Set(); + + /// Authenticable accounts linked to a . + public DbSet Users => Set(); + + /// Authentication credentials (OIDC subject or local password hash) per user. + public DbSet UserCredentials => Set(); + + /// Pending or consumed invitations issued by admins to onboard new members. + public DbSet Invitations => Set(); + + /// Shared household chores with per-occurrence completion tracking. + public DbSet HouseholdTasks => Set(); + + /// Completion markers (one row per task occurrence). + public DbSet TaskCompletions => Set(); + + /// Binary assets (wall images today; health/recipe attachments later). + public DbSet FileAssets => Set(); + + /// Wall posts โ€” the family "pizarra de cocina". + public DbSet WallMessages => Set(); + + /// 1-level replies to wall posts. + public DbSet WallComments => Set(); + + /// Emoji reactions to wall posts. + public DbSet WallReactions => Set(); + + /// Google accounts linked by adult users to mirror their calendar. + public DbSet GoogleAccounts => Set(); + + /// Specific Google calendars discovered under a linked account. + public DbSet LinkedCalendars => Set(); + + /// Mirrored Google Calendar events imported by the periodic sync engine. + public DbSet CalendarEvents => Set(); + + /// Weekly meal-plan slots โ€” one row per (family, date, slot). + public DbSet MealPlanSlots => Set(); + + /// Per-user notification toggles (email digest, task-assigned, etc.). + public DbSet UserNotificationPreferences => Set(); + + /// Per-user dashboard widget visibility + order. + public DbSet UserDashboardPreferences => Set(); + + /// Audit rows tracking that the daily digest has been processed for a (user, local date). + public DbSet EmailDigestRuns => Set(); + + /// Lightweight medical card per family member (1:1). + public DbSet HealthProfiles => Set(); + + /// Vaccination history per family member. + public DbSet Vaccinations => Set(); + + /// Active or past medications taken by family members. + public DbSet Medications => Set(); + + /// Lightweight school card per family member (1:1). + public DbSet SchoolProfiles => Set(); + + /// Weekly default of who takes / picks up each kid at school (drop-off + pick-up). + public DbSet SchoolDaySchedules => Set(); + + /// Per-date overrides or cancellations of the daily school commute. + public DbSet SchoolDayExceptions => Set(); + + /// Family-wide school holiday ranges that cancel bus + extracurriculars. + public DbSet SchoolHolidays => Set(); + + /// After-school activities per kid with their default caretakers. + public DbSet Extracurriculars => Set(); + + /// Per-date overrides and cancellations of extracurricular sessions. + public DbSet ExtracurricularExceptions => Set(); + + /// Recurring weekly entries in each member's agenda (work, gym, regular travel). + public DbSet MemberAgendaPatterns => Set(); + + /// Per-date overrides and ad-hoc additions to the recurring agenda. + public DbSet MemberAgendaExceptions => Set(); + + /// Long-lived API tokens used by external integrations (scripts, automatizaciones). + public DbSet IntegrationApiKeys => Set(); + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); + } + + /// + public override int SaveChanges() + { + StampAuditColumns(); + return base.SaveChanges(); + } + + /// + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + StampAuditColumns(); + return base.SaveChangesAsync(cancellationToken); + } + + private void StampAuditColumns() + { + var now = _timeProvider.GetUtcNow(); + var actor = _actorProvider?.GetActor() ?? "system"; + + foreach (var entry in ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Entity.CreatedAt = now; + entry.Entity.CreatedBy = actor; + break; + case EntityState.Modified: + entry.Entity.UpdatedAt = now; + entry.Entity.UpdatedBy = actor; + break; + } + } + } +} diff --git a/src/FamilyNido.Persistence/ApplicationDbContextFactory.cs b/src/FamilyNido.Persistence/ApplicationDbContextFactory.cs new file mode 100644 index 0000000..7cc227d --- /dev/null +++ b/src/FamilyNido.Persistence/ApplicationDbContextFactory.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace FamilyNido.Persistence; + +/// +/// Design-time factory used by dotnet ef tooling to build a context without +/// going through the ASP.NET host. Points at a placeholder local database; the +/// migrations generated here are provider-specific (PostgreSQL) but do not execute +/// against any real server. +/// +public sealed class ApplicationDbContextFactory : IDesignTimeDbContextFactory +{ + /// + public ApplicationDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Port=5432;Database=FamilyNido;Username=FamilyNido;Password=FamilyNido") + .UseSnakeCaseNamingConvention() + .Options; + + return new ApplicationDbContext(options); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/CalendarEventConfiguration.cs b/src/FamilyNido.Persistence/Configurations/CalendarEventConfiguration.cs new file mode 100644 index 0000000..3d8679d --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/CalendarEventConfiguration.cs @@ -0,0 +1,68 @@ +using FamilyNido.Domain.Calendar; +using FamilyNido.Domain.Families; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class CalendarEventConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("calendar_events"); + + builder.HasKey(e => e.Id); + + builder.Property(e => e.ExternalEventId).HasMaxLength(1024).IsRequired(); + builder.Property(e => e.IcalUid).HasMaxLength(1024); + builder.Property(e => e.Title).HasMaxLength(500).IsRequired(); + builder.Property(e => e.Description).HasColumnType("text"); + builder.Property(e => e.Location).HasMaxLength(500); + builder.Property(e => e.OriginalTimeZone).HasMaxLength(64); + builder.Property(e => e.HtmlLink).HasMaxLength(1024); + + builder.Property(e => e.StartAt).IsRequired(); + builder.Property(e => e.EndAt).IsRequired(); + builder.Property(e => e.IsAllDay).HasDefaultValue(false); + + builder.Property(e => e.CreatedAt).IsRequired(); + builder.Property(e => e.CreatedBy).HasMaxLength(200); + builder.Property(e => e.UpdatedBy).HasMaxLength(200); + + // FK to Family โ€” restrict so we never orphan events at family deletion time. + builder.HasOne(e => e.Family) + .WithMany() + .HasForeignKey(e => e.FamilyId) + .OnDelete(DeleteBehavior.Restrict); + + // Unique per (calendar, external id) โ€” upsert key for the sync engine. + builder.HasIndex(e => new { e.LinkedCalendarId, e.ExternalEventId }).IsUnique(); + + // Range queries by family + time window โ€” the dominant access pattern. + builder.HasIndex(e => new { e.FamilyId, e.StartAt }); + builder.HasIndex(e => new { e.FamilyId, e.EndAt }); + + // M:N with FamilyMember for "this event concerns these people". Cascade on + // both ends: if an event is removed (Google cancellation) the relations + // disappear; if a member is deleted the relations follow them out. + builder.HasMany(e => e.RelatedMembers) + .WithMany() + .UsingEntity>( + "calendar_event_members", + right => right.HasOne() + .WithMany() + .HasForeignKey("family_member_id") + .OnDelete(DeleteBehavior.Cascade), + left => left.HasOne() + .WithMany() + .HasForeignKey("calendar_event_id") + .OnDelete(DeleteBehavior.Cascade), + join => + { + join.ToTable("calendar_event_members"); + join.HasKey("calendar_event_id", "family_member_id"); + }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/EmailDigestRunConfiguration.cs b/src/FamilyNido.Persistence/Configurations/EmailDigestRunConfiguration.cs new file mode 100644 index 0000000..19e1823 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/EmailDigestRunConfiguration.cs @@ -0,0 +1,25 @@ +using FamilyNido.Domain.Notifications; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class EmailDigestRunConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("email_digest_runs"); + + // Composite PK is the deduplication contract โ€” at most one row per (user, local-date). + builder.HasKey(r => new { r.UserId, r.LocalDate }); + + builder.Property(r => r.SentAt).IsRequired(); + + builder.HasOne(r => r.User) + .WithMany() + .HasForeignKey(r => r.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/ExtracurricularConfiguration.cs b/src/FamilyNido.Persistence/Configurations/ExtracurricularConfiguration.cs new file mode 100644 index 0000000..a01e481 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/ExtracurricularConfiguration.cs @@ -0,0 +1,48 @@ +using FamilyNido.Domain.School; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class ExtracurricularConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("extracurriculars"); + builder.HasKey(e => e.Id); + + builder.Property(e => e.Name).HasMaxLength(120).IsRequired(); + builder.Property(e => e.Location).HasMaxLength(160); + builder.Property(e => e.ContactPhone).HasMaxLength(40); + builder.Property(e => e.Notes).HasMaxLength(2000); + builder.Property(e => e.IsArchived).HasDefaultValue(false); + + builder.Property(e => e.CreatedAt).IsRequired(); + builder.Property(e => e.CreatedBy).HasMaxLength(200); + builder.Property(e => e.UpdatedBy).HasMaxLength(200); + + builder.HasOne(e => e.FamilyMember) + .WithMany() + .HasForeignKey(e => e.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.DefaultDropoffMember) + .WithMany() + .HasForeignKey(e => e.DefaultDropoffMemberId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(e => e.DefaultPickupMember) + .WithMany() + .HasForeignKey(e => e.DefaultPickupMemberId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasMany(e => e.Exceptions) + .WithOne(x => x.Extracurricular) + .HasForeignKey(x => x.ExtracurricularId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(e => new { e.FamilyId, e.IsArchived }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/ExtracurricularExceptionConfiguration.cs b/src/FamilyNido.Persistence/Configurations/ExtracurricularExceptionConfiguration.cs new file mode 100644 index 0000000..0c93e07 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/ExtracurricularExceptionConfiguration.cs @@ -0,0 +1,35 @@ +using FamilyNido.Domain.School; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class ExtracurricularExceptionConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("extracurricular_exceptions"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Notes).HasMaxLength(500); + + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.CreatedBy).HasMaxLength(200); + builder.Property(x => x.UpdatedBy).HasMaxLength(200); + + builder.HasOne(x => x.DropoffMember) + .WithMany() + .HasForeignKey(x => x.DropoffMemberId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(x => x.PickupMember) + .WithMany() + .HasForeignKey(x => x.PickupMemberId) + .OnDelete(DeleteBehavior.SetNull); + + // At most one exception per (extracurricular, date). + builder.HasIndex(x => new { x.ExtracurricularId, x.Date }).IsUnique(); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/FamilyConfiguration.cs b/src/FamilyNido.Persistence/Configurations/FamilyConfiguration.cs new file mode 100644 index 0000000..78c5624 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/FamilyConfiguration.cs @@ -0,0 +1,31 @@ +using FamilyNido.Domain.Families; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class FamilyConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("families"); + + builder.HasKey(f => f.Id); + + builder.Property(f => f.Name).HasMaxLength(120).IsRequired(); + builder.Property(f => f.TimeZone).HasMaxLength(64).IsRequired(); + builder.Property(f => f.Locale).HasMaxLength(16).IsRequired(); + builder.Property(f => f.LocationLabel).HasMaxLength(120); + + builder.Property(f => f.CreatedAt).IsRequired(); + builder.Property(f => f.CreatedBy).HasMaxLength(200); + builder.Property(f => f.UpdatedBy).HasMaxLength(200); + + builder.HasMany(f => f.Members) + .WithOne(m => m.Family) + .HasForeignKey(m => m.FamilyId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/FamilyMemberConfiguration.cs b/src/FamilyNido.Persistence/Configurations/FamilyMemberConfiguration.cs new file mode 100644 index 0000000..9113174 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/FamilyMemberConfiguration.cs @@ -0,0 +1,38 @@ +using FamilyNido.Domain.Families; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class FamilyMemberConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("family_members"); + + builder.HasKey(m => m.Id); + + builder.Property(m => m.DisplayName).HasMaxLength(120).IsRequired(); + builder.Property(m => m.ColorHex).HasMaxLength(7).IsRequired(); + builder.Property(m => m.PhotoPath).HasMaxLength(260); + builder.Property(m => m.ContactEmail).HasMaxLength(254); + builder.Property(m => m.MemberType).HasConversion().HasMaxLength(16); + builder.Property(m => m.IsActive).HasDefaultValue(true); + + builder.Property(m => m.CreatedAt).IsRequired(); + builder.Property(m => m.CreatedBy).HasMaxLength(200); + builder.Property(m => m.UpdatedBy).HasMaxLength(200); + + // One-to-one (optional) with User. FK lives on FamilyMember so we can have + // many members without users (children, grandparents). + builder.HasOne(m => m.User) + .WithOne(u => u.FamilyMember) + .HasForeignKey(m => m.UserId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(m => new { m.FamilyId, m.IsActive }); + builder.HasIndex(m => m.UserId).IsUnique().HasFilter("user_id IS NOT NULL"); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/FileAssetConfiguration.cs b/src/FamilyNido.Persistence/Configurations/FileAssetConfiguration.cs new file mode 100644 index 0000000..aa20d69 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/FileAssetConfiguration.cs @@ -0,0 +1,38 @@ +using FamilyNido.Domain.Files; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class FileAssetConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("file_assets"); + + builder.HasKey(a => a.Id); + + builder.Property(a => a.RelativePath).HasMaxLength(400).IsRequired(); + builder.Property(a => a.ContentType).HasMaxLength(80).IsRequired(); + builder.Property(a => a.SizeBytes).IsRequired(); + + builder.Property(a => a.CreatedAt).IsRequired(); + builder.Property(a => a.CreatedBy).HasMaxLength(200); + builder.Property(a => a.UpdatedBy).HasMaxLength(200); + + builder.HasOne(a => a.Family) + .WithMany() + .HasForeignKey(a => a.FamilyId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(a => a.OwnerMember) + .WithMany() + .HasForeignKey(a => a.OwnerMemberId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(a => a.RelativePath).IsUnique(); + builder.HasIndex(a => a.FamilyId); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/GoogleAccountConfiguration.cs b/src/FamilyNido.Persistence/Configurations/GoogleAccountConfiguration.cs new file mode 100644 index 0000000..9fe6d29 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/GoogleAccountConfiguration.cs @@ -0,0 +1,53 @@ +using FamilyNido.Domain.Calendar; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class GoogleAccountConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("google_accounts"); + + builder.HasKey(a => a.Id); + + builder.Property(a => a.Email).HasMaxLength(320).IsRequired(); + builder.Property(a => a.DisplayName).HasMaxLength(200); + + // Refresh token ciphertext: short enough that a varchar fits, but text is safer + // since Data Protection envelopes can grow (purpose strings, key rotation). + builder.Property(a => a.EncryptedRefreshToken).HasColumnType("text").IsRequired(); + + builder.Property(a => a.LastError).HasMaxLength(2000); + builder.Property(a => a.IsRevoked).HasDefaultValue(false); + + builder.Property(a => a.CreatedAt).IsRequired(); + builder.Property(a => a.CreatedBy).HasMaxLength(200); + builder.Property(a => a.UpdatedBy).HasMaxLength(200); + + // FK to Family โ€” restrict so a family with linked accounts cannot be deleted by accident. + builder.HasOne(a => a.Family) + .WithMany() + .HasForeignKey(a => a.FamilyId) + .OnDelete(DeleteBehavior.Restrict); + + // FK to User โ€” cascade so removing the user wipes their Google links and (transitively) + // their cached events. Coherent with "users own their integrations". + builder.HasOne(a => a.User) + .WithMany() + .HasForeignKey(a => a.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(a => a.Calendars) + .WithOne(c => c.GoogleAccount) + .HasForeignKey(c => c.GoogleAccountId) + .OnDelete(DeleteBehavior.Cascade); + + // Prevent the same user from linking the same Google account twice. + builder.HasIndex(a => new { a.UserId, a.Email }).IsUnique(); + builder.HasIndex(a => a.FamilyId); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/HealthProfileConfiguration.cs b/src/FamilyNido.Persistence/Configurations/HealthProfileConfiguration.cs new file mode 100644 index 0000000..51bd18e --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/HealthProfileConfiguration.cs @@ -0,0 +1,33 @@ +using FamilyNido.Domain.Health; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class HealthProfileConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("health_profiles"); + builder.HasKey(p => p.Id); + + builder.Property(p => p.BloodType).HasMaxLength(8); + builder.Property(p => p.Allergies).HasMaxLength(2000); + builder.Property(p => p.ChronicConditions).HasMaxLength(2000); + builder.Property(p => p.Notes).HasMaxLength(4000); + + // Audit columns for AuditableEntity. + builder.Property(p => p.CreatedAt).IsRequired(); + builder.Property(p => p.CreatedBy).HasMaxLength(200); + builder.Property(p => p.UpdatedBy).HasMaxLength(200); + + // 1:1 with the member โ€” unique index doubles as the lookup key. + builder.HasOne(p => p.FamilyMember) + .WithOne() + .HasForeignKey(p => p.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(p => p.FamilyMemberId).IsUnique(); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/HouseholdTaskConfiguration.cs b/src/FamilyNido.Persistence/Configurations/HouseholdTaskConfiguration.cs new file mode 100644 index 0000000..a6fae1a --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/HouseholdTaskConfiguration.cs @@ -0,0 +1,88 @@ +using FamilyNido.Domain.Families; +using FamilyNido.Domain.HouseholdTasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class HouseholdTaskConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("household_tasks"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Title).HasMaxLength(120).IsRequired(); + builder.Property(t => t.Description).HasMaxLength(2000); + builder.Property(t => t.Category).HasMaxLength(40).IsRequired().HasDefaultValue("General"); + + // Recurrence as a readable string so DB rows are self-documenting. + builder.Property(t => t.Recurrence).HasConversion().HasMaxLength(16).IsRequired(); + + // Weekly bitmask persisted as smallint (fits all 7 flags + combinations). + builder.Property(t => t.WeeklyDays).HasConversion(); + + builder.Property(t => t.MonthlyDay); + builder.Property(t => t.TimeOfDay); + builder.Property(t => t.StartDate).IsRequired(); + builder.Property(t => t.DueDate); + builder.Property(t => t.IsArchived).HasDefaultValue(false); + builder.Property(t => t.IsFloating).HasDefaultValue(false); + + // Points default 5 mirrors the new-task UX default and seeds existing + // rows on the migration (HasDefaultValue is honoured by EF when adding + // a non-nullable column to a populated table). + builder.Property(t => t.Points).HasDefaultValue(5).IsRequired(); + + builder.Property(t => t.CreatedAt).IsRequired(); + builder.Property(t => t.CreatedBy).HasMaxLength(200); + builder.Property(t => t.UpdatedBy).HasMaxLength(200); + + // FK to Family โ€” restrict so a family with live tasks cannot be deleted. + builder.HasOne(t => t.Family) + .WithMany() + .HasForeignKey(t => t.FamilyId) + .OnDelete(DeleteBehavior.Restrict); + + // FK to creator โ€” restrict so deleting a member with authored tasks fails loud. + builder.HasOne(t => t.CreatedByMember) + .WithMany() + .HasForeignKey(t => t.CreatedByMemberId) + .OnDelete(DeleteBehavior.Restrict); + + // FK to responsible member โ€” set null on delete so removing a member doesn't + // wipe their tasks; the task simply becomes "open" again. + builder.HasOne(t => t.ResponsibleMember) + .WithMany() + .HasForeignKey(t => t.ResponsibleMemberId) + .OnDelete(DeleteBehavior.SetNull); + + // M:N with FamilyMember for related members (the "about whom" of the task, + // not its executor โ€” that's ResponsibleMemberId above). The join table is + // explicit so schema diffs stay readable. + builder.HasMany(t => t.RelatedMembers) + .WithMany() + .UsingEntity>( + "household_task_related_members", + right => right.HasOne() + .WithMany() + .HasForeignKey("family_member_id") + .OnDelete(DeleteBehavior.Cascade), + left => left.HasOne() + .WithMany() + .HasForeignKey("task_id") + .OnDelete(DeleteBehavior.Cascade), + join => + { + join.ToTable("household_task_related_members"); + join.HasKey("task_id", "family_member_id"); + }); + + builder.HasIndex(t => t.FamilyId); + builder.HasIndex(t => new { t.FamilyId, t.IsArchived }); + builder.HasIndex(t => t.ResponsibleMemberId); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/IntegrationApiKeyConfiguration.cs b/src/FamilyNido.Persistence/Configurations/IntegrationApiKeyConfiguration.cs new file mode 100644 index 0000000..ca0a91a --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/IntegrationApiKeyConfiguration.cs @@ -0,0 +1,38 @@ +using FamilyNido.Domain.Integrations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class IntegrationApiKeyConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("integration_api_keys"); + builder.HasKey(k => k.Id); + + builder.Property(k => k.Name).IsRequired().HasMaxLength(80); + // SHA-256 hex digest is exactly 64 chars; clamp to that to keep the + // index entry tight and to make a malformed value visually obvious. + builder.Property(k => k.TokenHash).IsRequired().HasMaxLength(64); + builder.Property(k => k.Prefix).IsRequired().HasMaxLength(16); + + // Unique on the hash because that is the lookup key on every request. + builder.HasIndex(k => k.TokenHash).IsUnique(); + builder.HasIndex(k => k.FamilyId); + + builder.HasOne(k => k.Family) + .WithMany() + .HasForeignKey(k => k.FamilyId) + .OnDelete(DeleteBehavior.Cascade); + + // Restrict so deleting the author member surfaces the conflict instead + // of silently leaving a token attributed to a non-existent person. + builder.HasOne(k => k.AuthorMember) + .WithMany() + .HasForeignKey(k => k.AuthorMemberId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/InvitationConfiguration.cs b/src/FamilyNido.Persistence/Configurations/InvitationConfiguration.cs new file mode 100644 index 0000000..dea5cca --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/InvitationConfiguration.cs @@ -0,0 +1,45 @@ +using FamilyNido.Domain.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class InvitationConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("invitations"); + + builder.HasKey(i => i.Id); + + builder.Property(i => i.Email).HasMaxLength(254).IsRequired(); + builder.Property(i => i.RoleOnAccept).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(i => i.TokenHash).HasColumnType("bytea").IsRequired(); + builder.Property(i => i.ExpiresAt).IsRequired(); + + builder.Property(i => i.CreatedAt).IsRequired(); + builder.Property(i => i.CreatedBy).HasMaxLength(200); + builder.Property(i => i.UpdatedBy).HasMaxLength(200); + + // Token lookup is the hot path during /accept โ€” UNIQUE both for safety + // and to enable index-only lookups by hash. + builder.HasIndex(i => i.TokenHash).IsUnique(); + + // Listing pending invitations of a family is a common admin query; the + // partial index keeps it tiny since most rows eventually move to + // consumed/revoked state. + builder.HasIndex(i => new { i.FamilyId, i.ConsumedAt, i.RevokedAt }); + + builder.HasIndex(i => i.FamilyMemberId); + + // FK to FamilyMember without cascade: an admin must revoke pending + // invitations explicitly before deleting the member. Prevents + // accidentally orphaning rows mid-flow. + builder.HasOne(i => i.FamilyMember) + .WithMany() + .HasForeignKey(i => i.FamilyMemberId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/LinkedCalendarConfiguration.cs b/src/FamilyNido.Persistence/Configurations/LinkedCalendarConfiguration.cs new file mode 100644 index 0000000..dd87739 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/LinkedCalendarConfiguration.cs @@ -0,0 +1,46 @@ +using FamilyNido.Domain.Calendar; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class LinkedCalendarConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("linked_calendars"); + + builder.HasKey(c => c.Id); + + builder.Property(c => c.ExternalCalendarId).HasMaxLength(320).IsRequired(); + builder.Property(c => c.Summary).HasMaxLength(200).IsRequired(); + builder.Property(c => c.Description).HasMaxLength(2000); + builder.Property(c => c.ColorHex).HasMaxLength(7); + builder.Property(c => c.IsImported).HasDefaultValue(false); + + // Sync tokens from Google can grow long; text rather than varchar. + builder.Property(c => c.SyncToken).HasColumnType("text"); + builder.Property(c => c.LastSyncedAt); + + builder.Property(c => c.CreatedAt).IsRequired(); + builder.Property(c => c.CreatedBy).HasMaxLength(200); + builder.Property(c => c.UpdatedBy).HasMaxLength(200); + + // FK to FamilyMember โ€” set null so removing the member just unassigns the calendar. + builder.HasOne(c => c.FamilyMember) + .WithMany() + .HasForeignKey(c => c.FamilyMemberId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasMany(c => c.Events) + .WithOne(e => e.LinkedCalendar) + .HasForeignKey(e => e.LinkedCalendarId) + .OnDelete(DeleteBehavior.Cascade); + + // A given Google calendar appears at most once per linked account. + builder.HasIndex(c => new { c.GoogleAccountId, c.ExternalCalendarId }).IsUnique(); + builder.HasIndex(c => new { c.GoogleAccountId, c.IsImported }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/MealPlanSlotConfiguration.cs b/src/FamilyNido.Persistence/Configurations/MealPlanSlotConfiguration.cs new file mode 100644 index 0000000..a2713e1 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/MealPlanSlotConfiguration.cs @@ -0,0 +1,39 @@ +using FamilyNido.Domain.Meals; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class MealPlanSlotConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("meal_plan_slots"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Date).IsRequired(); + // Slot stored as text ("Lunch"/"Dinner") so a quick `psql` peek is + // self-documenting; trivially extends to Breakfast/Snack later. + builder.Property(s => s.Slot).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(s => s.FirstCourse).HasMaxLength(120); + builder.Property(s => s.SecondCourse).HasMaxLength(120); + + builder.Property(s => s.CreatedAt).IsRequired(); + builder.Property(s => s.CreatedBy).HasMaxLength(200); + builder.Property(s => s.UpdatedBy).HasMaxLength(200); + + builder.HasOne(s => s.Family) + .WithMany() + .HasForeignKey(s => s.FamilyId) + .OnDelete(DeleteBehavior.Restrict); + + // One row per (family, date, slot): upsert key. + builder.HasIndex(s => new { s.FamilyId, s.Date, s.Slot }).IsUnique(); + // Powers the autocomplete query (prefix search across both courses). + builder.HasIndex(s => new { s.FamilyId, s.FirstCourse }); + builder.HasIndex(s => new { s.FamilyId, s.SecondCourse }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/MedicationConfiguration.cs b/src/FamilyNido.Persistence/Configurations/MedicationConfiguration.cs new file mode 100644 index 0000000..c976aff --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/MedicationConfiguration.cs @@ -0,0 +1,32 @@ +using FamilyNido.Domain.Health; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class MedicationConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("medications"); + builder.HasKey(m => m.Id); + + builder.Property(m => m.Name).HasMaxLength(120).IsRequired(); + builder.Property(m => m.Dose).HasMaxLength(80); + builder.Property(m => m.Frequency).HasMaxLength(120); + builder.Property(m => m.Instructions).HasMaxLength(2000); + + builder.Property(m => m.CreatedAt).IsRequired(); + builder.Property(m => m.CreatedBy).HasMaxLength(200); + builder.Property(m => m.UpdatedBy).HasMaxLength(200); + + builder.HasOne(m => m.FamilyMember) + .WithMany() + .HasForeignKey(m => m.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(m => new { m.FamilyMemberId, m.StartDate }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/MemberAgendaExceptionConfiguration.cs b/src/FamilyNido.Persistence/Configurations/MemberAgendaExceptionConfiguration.cs new file mode 100644 index 0000000..6a8ac01 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/MemberAgendaExceptionConfiguration.cs @@ -0,0 +1,44 @@ +using FamilyNido.Domain.Agenda; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class MemberAgendaExceptionConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("member_agenda_exceptions"); + builder.HasKey(e => e.Id); + + builder.Property(e => e.TransportMode).HasConversion(); + + builder.Property(e => e.Label).HasMaxLength(120); + builder.Property(e => e.Location).HasMaxLength(200); + builder.Property(e => e.Notes).HasMaxLength(500); + + builder.Property(e => e.CreatedAt).IsRequired(); + builder.Property(e => e.CreatedBy).HasMaxLength(200); + builder.Property(e => e.UpdatedBy).HasMaxLength(200); + + builder.HasOne(e => e.FamilyMember) + .WithMany() + .HasForeignKey(e => e.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + + // When a pattern is deleted, drop its overrides too โ€” they no longer + // refer to anything. Ad-hoc rows (PatternId is null) are unaffected. + builder.HasOne(e => e.Pattern) + .WithMany() + .HasForeignKey(e => e.PatternId) + .OnDelete(DeleteBehavior.Cascade); + + // PatternId is nullable: PG treats nulls as distinct, so multiple + // ad-hoc rows on the same (member, date) coexist while pattern + // overrides stay unique per (member, date, pattern). + builder.HasIndex(e => new { e.FamilyMemberId, e.Date, e.PatternId }).IsUnique(); + builder.HasIndex(e => new { e.FamilyId, e.Date }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/MemberAgendaPatternConfiguration.cs b/src/FamilyNido.Persistence/Configurations/MemberAgendaPatternConfiguration.cs new file mode 100644 index 0000000..5b1ea51 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/MemberAgendaPatternConfiguration.cs @@ -0,0 +1,35 @@ +using FamilyNido.Domain.Agenda; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class MemberAgendaPatternConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("member_agenda_patterns"); + builder.HasKey(p => p.Id); + + builder.Property(p => p.DayOfWeek).HasConversion(); + builder.Property(p => p.TransportMode).HasConversion(); + + builder.Property(p => p.Label).HasMaxLength(120).IsRequired(); + builder.Property(p => p.Location).HasMaxLength(200); + builder.Property(p => p.Notes).HasMaxLength(500); + + builder.Property(p => p.CreatedAt).IsRequired(); + builder.Property(p => p.CreatedBy).HasMaxLength(200); + builder.Property(p => p.UpdatedBy).HasMaxLength(200); + + builder.HasOne(p => p.FamilyMember) + .WithMany() + .HasForeignKey(p => p.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(p => new { p.FamilyMemberId, p.DayOfWeek }); + builder.HasIndex(p => p.FamilyId); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/SchoolDayExceptionConfiguration.cs b/src/FamilyNido.Persistence/Configurations/SchoolDayExceptionConfiguration.cs new file mode 100644 index 0000000..204ed3a --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/SchoolDayExceptionConfiguration.cs @@ -0,0 +1,42 @@ +using FamilyNido.Domain.School; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class SchoolDayExceptionConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("school_day_exceptions"); + builder.HasKey(e => e.Id); + + builder.Property(e => e.Notes).HasMaxLength(500); + builder.Property(e => e.MorningTime); + builder.Property(e => e.AfternoonTime); + + builder.Property(e => e.CreatedAt).IsRequired(); + builder.Property(e => e.CreatedBy).HasMaxLength(200); + builder.Property(e => e.UpdatedBy).HasMaxLength(200); + + builder.HasOne(e => e.FamilyMember) + .WithMany() + .HasForeignKey(e => e.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.DropoffMember) + .WithMany() + .HasForeignKey(e => e.DropoffMemberId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(e => e.PickupMember) + .WithMany() + .HasForeignKey(e => e.PickupMemberId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(e => new { e.FamilyMemberId, e.Date }).IsUnique(); + builder.HasIndex(e => new { e.FamilyId, e.Date }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/SchoolDayScheduleConfiguration.cs b/src/FamilyNido.Persistence/Configurations/SchoolDayScheduleConfiguration.cs new file mode 100644 index 0000000..e11a500 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/SchoolDayScheduleConfiguration.cs @@ -0,0 +1,35 @@ +using FamilyNido.Domain.School; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class SchoolDayScheduleConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("school_day_schedules"); + + // Composite PK: at most one row per (kid, weekday). + builder.HasKey(s => new { s.FamilyMemberId, s.DayOfWeek }); + + builder.Property(s => s.DayOfWeek).HasConversion(); + + builder.HasOne(s => s.FamilyMember) + .WithMany() + .HasForeignKey(s => s.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(s => s.DropoffMember) + .WithMany() + .HasForeignKey(s => s.DropoffMemberId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(s => s.PickupMember) + .WithMany() + .HasForeignKey(s => s.PickupMemberId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/SchoolHolidayConfiguration.cs b/src/FamilyNido.Persistence/Configurations/SchoolHolidayConfiguration.cs new file mode 100644 index 0000000..254bf35 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/SchoolHolidayConfiguration.cs @@ -0,0 +1,29 @@ +using FamilyNido.Domain.School; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class SchoolHolidayConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("school_holidays"); + builder.HasKey(h => h.Id); + + builder.Property(h => h.Label).HasMaxLength(120).IsRequired(); + + builder.Property(h => h.CreatedAt).IsRequired(); + builder.Property(h => h.CreatedBy).HasMaxLength(200); + builder.Property(h => h.UpdatedBy).HasMaxLength(200); + + builder.HasOne(h => h.Family) + .WithMany() + .HasForeignKey(h => h.FamilyId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(h => new { h.FamilyId, h.StartDate }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/SchoolProfileConfiguration.cs b/src/FamilyNido.Persistence/Configurations/SchoolProfileConfiguration.cs new file mode 100644 index 0000000..c24cbbe --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/SchoolProfileConfiguration.cs @@ -0,0 +1,33 @@ +using FamilyNido.Domain.School; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class SchoolProfileConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("school_profiles"); + builder.HasKey(p => p.Id); + + builder.Property(p => p.SchoolName).HasMaxLength(120); + builder.Property(p => p.Grade).HasMaxLength(60); + builder.Property(p => p.Tutor).HasMaxLength(120); + builder.Property(p => p.Notes).HasMaxLength(2000); + // TransportMode persisted as a readable string so the column self-documents. + builder.Property(p => p.TransportMode).HasConversion().HasMaxLength(8).IsRequired(); + + builder.Property(p => p.CreatedAt).IsRequired(); + builder.Property(p => p.CreatedBy).HasMaxLength(200); + builder.Property(p => p.UpdatedBy).HasMaxLength(200); + + builder.HasOne(p => p.FamilyMember) + .WithOne() + .HasForeignKey(p => p.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(p => p.FamilyMemberId).IsUnique(); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/TaskCompletionConfiguration.cs b/src/FamilyNido.Persistence/Configurations/TaskCompletionConfiguration.cs new file mode 100644 index 0000000..b82de8b --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/TaskCompletionConfiguration.cs @@ -0,0 +1,34 @@ +using FamilyNido.Domain.HouseholdTasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class TaskCompletionConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("task_completions"); + + builder.HasKey(c => c.Id); + + builder.Property(c => c.OccurrenceDate).IsRequired(); + builder.Property(c => c.CompletedAt).IsRequired(); + builder.Property(c => c.Note).HasMaxLength(500); + + builder.HasOne(c => c.Task) + .WithMany(t => t.Completions) + .HasForeignKey(c => c.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + // SET NULL preserves the completion row when the member is deleted. + builder.HasOne(c => c.CompletedBy) + .WithMany() + .HasForeignKey(c => c.CompletedByMemberId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(c => new { c.TaskId, c.OccurrenceDate }).IsUnique(); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/UserConfiguration.cs b/src/FamilyNido.Persistence/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..dfddd71 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/UserConfiguration.cs @@ -0,0 +1,38 @@ +using FamilyNido.Domain.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class UserConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + + builder.HasKey(u => u.Id); + + builder.Property(u => u.Email).HasMaxLength(254).IsRequired(); + builder.Property(u => u.DisplayName).HasMaxLength(120).IsRequired(); + builder.Property(u => u.Role).HasConversion().HasMaxLength(16).IsRequired(); + // BCP-47 tag, e.g. "es-ES" or "en-US". Stored as a string so adding a + // new locale never needs a schema change. + builder.Property(u => u.PreferredLanguage).HasMaxLength(16).IsRequired().HasDefaultValue("es-ES"); + + builder.Property(u => u.CreatedAt).IsRequired(); + builder.Property(u => u.CreatedBy).HasMaxLength(200); + builder.Property(u => u.UpdatedBy).HasMaxLength(200); + + builder.HasIndex(u => u.Email).IsUnique(); + + // Cascade delete a user โ†’ all its credentials. Not the other way around: + // a user without credentials still exists (briefly) during credential + // rotation, the API just won't authenticate them. + builder.HasMany(u => u.Credentials) + .WithOne(c => c.User) + .HasForeignKey(c => c.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/UserCredentialConfiguration.cs b/src/FamilyNido.Persistence/Configurations/UserCredentialConfiguration.cs new file mode 100644 index 0000000..4782513 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/UserCredentialConfiguration.cs @@ -0,0 +1,42 @@ +using FamilyNido.Domain.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class UserCredentialConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_credentials", t => + { + // Coherence: an Oidc credential carries a provider key (sub) and no + // password; a Local credential carries a password hash and no key. + t.HasCheckConstraint( + "ck_user_credentials_shape", + "(provider = 0 AND provider_key IS NOT NULL AND password_hash IS NULL) " + + "OR (provider = 1 AND provider_key IS NULL AND password_hash IS NOT NULL)"); + }); + + builder.HasKey(c => c.Id); + + builder.Property(c => c.Provider).HasConversion().IsRequired(); + builder.Property(c => c.ProviderKey).HasMaxLength(255); + builder.Property(c => c.PasswordHash).HasMaxLength(512); + + builder.Property(c => c.CreatedAt).IsRequired(); + builder.Property(c => c.CreatedBy).HasMaxLength(200); + builder.Property(c => c.UpdatedBy).HasMaxLength(200); + + // OIDC sub is globally unique across providers โ€” we only ever support one + // OIDC issuer per instance, so a partial unique index on (provider, key) + // is enough to protect against duplicate accounts. + builder.HasIndex(c => new { c.Provider, c.ProviderKey }) + .IsUnique() + .HasFilter("provider_key IS NOT NULL"); + + builder.HasIndex(c => c.UserId); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/UserDashboardPreferencesConfiguration.cs b/src/FamilyNido.Persistence/Configurations/UserDashboardPreferencesConfiguration.cs new file mode 100644 index 0000000..10d4b86 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/UserDashboardPreferencesConfiguration.cs @@ -0,0 +1,24 @@ +using FamilyNido.Domain.Notifications; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class UserDashboardPreferencesConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_dashboard_preferences"); + + builder.HasKey(p => p.UserId); + + builder.Property(p => p.WidgetsJson).HasColumnType("text").IsRequired(); + + builder.HasOne(p => p.User) + .WithOne() + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/UserNotificationPreferencesConfiguration.cs b/src/FamilyNido.Persistence/Configurations/UserNotificationPreferencesConfiguration.cs new file mode 100644 index 0000000..1566435 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/UserNotificationPreferencesConfiguration.cs @@ -0,0 +1,28 @@ +using FamilyNido.Domain.Notifications; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class UserNotificationPreferencesConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_notification_preferences"); + + builder.HasKey(p => p.UserId); + + builder.Property(p => p.EmailEnabled).HasDefaultValue(true); + builder.Property(p => p.DigestEnabled).HasDefaultValue(true); + builder.Property(p => p.TaskAssignedEnabled).HasDefaultValue(true); + builder.Property(p => p.WallMentionEnabled).HasDefaultValue(true); + + // Cascade so removing a user wipes their preferences automatically. + builder.HasOne(p => p.User) + .WithOne() + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/VaccinationConfiguration.cs b/src/FamilyNido.Persistence/Configurations/VaccinationConfiguration.cs new file mode 100644 index 0000000..0ddf671 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/VaccinationConfiguration.cs @@ -0,0 +1,30 @@ +using FamilyNido.Domain.Health; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class VaccinationConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("vaccinations"); + builder.HasKey(v => v.Id); + + builder.Property(v => v.Name).HasMaxLength(120).IsRequired(); + builder.Property(v => v.Notes).HasMaxLength(2000); + + builder.Property(v => v.CreatedAt).IsRequired(); + builder.Property(v => v.CreatedBy).HasMaxLength(200); + builder.Property(v => v.UpdatedBy).HasMaxLength(200); + + builder.HasOne(v => v.FamilyMember) + .WithMany() + .HasForeignKey(v => v.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(v => new { v.FamilyMemberId, v.Date }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/WallCommentConfiguration.cs b/src/FamilyNido.Persistence/Configurations/WallCommentConfiguration.cs new file mode 100644 index 0000000..44dbca7 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/WallCommentConfiguration.cs @@ -0,0 +1,31 @@ +using FamilyNido.Domain.Wall; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class WallCommentConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("wall_comments"); + + builder.HasKey(c => c.Id); + + builder.Property(c => c.Text).HasMaxLength(2000).IsRequired(); + builder.Property(c => c.TextHtml).HasMaxLength(4000).IsRequired(); + + builder.Property(c => c.CreatedAt).IsRequired(); + builder.Property(c => c.CreatedBy).HasMaxLength(200); + builder.Property(c => c.UpdatedBy).HasMaxLength(200); + + builder.HasOne(c => c.AuthorMember) + .WithMany() + .HasForeignKey(c => c.AuthorMemberId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(c => new { c.MessageId, c.CreatedAt }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/WallCommentMentionConfiguration.cs b/src/FamilyNido.Persistence/Configurations/WallCommentMentionConfiguration.cs new file mode 100644 index 0000000..a75ca83 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/WallCommentMentionConfiguration.cs @@ -0,0 +1,29 @@ +using FamilyNido.Domain.Wall; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class WallCommentMentionConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("wall_comment_mentions"); + + builder.HasKey(x => new { x.CommentId, x.FamilyMemberId }); + + builder.HasOne(x => x.Comment) + .WithMany(c => c.Mentions) + .HasForeignKey(x => x.CommentId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(x => x.FamilyMember) + .WithMany() + .HasForeignKey(x => x.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => x.FamilyMemberId); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/WallMessageConfiguration.cs b/src/FamilyNido.Persistence/Configurations/WallMessageConfiguration.cs new file mode 100644 index 0000000..2019425 --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/WallMessageConfiguration.cs @@ -0,0 +1,54 @@ +using FamilyNido.Domain.Wall; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class WallMessageConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("wall_messages"); + + builder.HasKey(m => m.Id); + + builder.Property(m => m.Text).HasMaxLength(4000).IsRequired(); + builder.Property(m => m.TextHtml).HasMaxLength(8000).IsRequired(); + builder.Property(m => m.IsPinned).HasDefaultValue(false); + + builder.Property(m => m.CreatedAt).IsRequired(); + builder.Property(m => m.CreatedBy).HasMaxLength(200); + builder.Property(m => m.UpdatedBy).HasMaxLength(200); + + builder.HasOne(m => m.Family) + .WithMany() + .HasForeignKey(m => m.FamilyId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(m => m.AuthorMember) + .WithMany() + .HasForeignKey(m => m.AuthorMemberId) + .OnDelete(DeleteBehavior.Restrict); + + // ImageFile nullable; set null on delete keeps the message. + builder.HasOne(m => m.ImageFile) + .WithMany() + .HasForeignKey(m => m.ImageFileId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasMany(m => m.Comments) + .WithOne(c => c.Message) + .HasForeignKey(c => c.MessageId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(m => m.Reactions) + .WithOne(r => r.Message) + .HasForeignKey(r => r.MessageId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(m => new { m.FamilyId, m.CreatedAt }); + builder.HasIndex(m => new { m.FamilyId, m.IsPinned, m.PinnedAt }); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/WallMessageMentionConfiguration.cs b/src/FamilyNido.Persistence/Configurations/WallMessageMentionConfiguration.cs new file mode 100644 index 0000000..74fa41e --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/WallMessageMentionConfiguration.cs @@ -0,0 +1,32 @@ +using FamilyNido.Domain.Wall; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class WallMessageMentionConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("wall_message_mentions"); + + // Composite PK enforces uniqueness per (message, member) โ€” a single + // member can only be mentioned once per message regardless of repeats. + builder.HasKey(x => new { x.MessageId, x.FamilyMemberId }); + + builder.HasOne(x => x.Message) + .WithMany(m => m.Mentions) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(x => x.FamilyMember) + .WithMany() + .HasForeignKey(x => x.FamilyMemberId) + .OnDelete(DeleteBehavior.Cascade); + + // Reverse lookup "messages I'm mentioned in". + builder.HasIndex(x => x.FamilyMemberId); + } +} diff --git a/src/FamilyNido.Persistence/Configurations/WallReactionConfiguration.cs b/src/FamilyNido.Persistence/Configurations/WallReactionConfiguration.cs new file mode 100644 index 0000000..97bfd8e --- /dev/null +++ b/src/FamilyNido.Persistence/Configurations/WallReactionConfiguration.cs @@ -0,0 +1,28 @@ +using FamilyNido.Domain.Wall; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FamilyNido.Persistence.Configurations; + +/// EF Core configuration for . +public sealed class WallReactionConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("wall_reactions"); + + builder.HasKey(r => r.Id); + + builder.Property(r => r.Emoji).HasMaxLength(16).IsRequired(); + builder.Property(r => r.ReactedAt).IsRequired(); + + builder.HasOne(r => r.Member) + .WithMany() + .HasForeignKey(r => r.MemberId) + .OnDelete(DeleteBehavior.Cascade); + + // One reaction per (message, member, emoji). Idempotent add/remove uses this. + builder.HasIndex(r => new { r.MessageId, r.MemberId, r.Emoji }).IsUnique(); + } +} diff --git a/src/FamilyNido.Persistence/DependencyInjection.cs b/src/FamilyNido.Persistence/DependencyInjection.cs new file mode 100644 index 0000000..0fa2c10 --- /dev/null +++ b/src/FamilyNido.Persistence/DependencyInjection.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace FamilyNido.Persistence; + +/// +/// Composition root for the persistence layer. Registers EF Core against +/// PostgreSQL with snake_case naming conventions. +/// +public static class DependencyInjection +{ + /// + /// Registers against the "Postgres" + /// connection string plus the default used for + /// audit timestamps. Callers are responsible for registering a matching + /// ; if none is registered, audit + /// columns default to "system". + /// + /// Target service collection. + /// Application configuration; reads connection string "Postgres". + /// The service collection for chaining. + public static IServiceCollection AddFamilyNidoPersistence( + this IServiceCollection services, + IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("Postgres") + ?? throw new InvalidOperationException( + "Missing connection string 'Postgres'. Set ConnectionStrings__Postgres in configuration."); + + services.AddDbContext(options => + { + options.UseNpgsql(connectionString, npgsql => + { + npgsql.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName); + }); + options.UseSnakeCaseNamingConvention(); + }); + + if (services.All(d => d.ServiceType != typeof(TimeProvider))) + { + services.AddSingleton(TimeProvider.System); + } + + return services; + } +} diff --git a/src/FamilyNido.Persistence/FamilyNido.Persistence.csproj b/src/FamilyNido.Persistence/FamilyNido.Persistence.csproj new file mode 100644 index 0000000..0a7dc6d --- /dev/null +++ b/src/FamilyNido.Persistence/FamilyNido.Persistence.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/src/FamilyNido.Persistence/ICurrentActorProvider.cs b/src/FamilyNido.Persistence/ICurrentActorProvider.cs new file mode 100644 index 0000000..8cfe7ce --- /dev/null +++ b/src/FamilyNido.Persistence/ICurrentActorProvider.cs @@ -0,0 +1,16 @@ +namespace FamilyNido.Persistence; + +/// +/// Supplies the identifier used when stamping audit columns (CreatedBy, +/// UpdatedBy). Kept as an abstraction so +/// stays independent of ASP.NET Core. The API layer provides the real +/// implementation that reads from the authenticated principal. +/// +public interface ICurrentActorProvider +{ + /// + /// Returns a short, stable identifier for the caller. Defaults such as + /// "system" are acceptable for background or pre-auth contexts. + /// + string GetActor(); +} diff --git a/src/FamilyNido.Persistence/Migrations/20260421210956_InitialCreate.Designer.cs b/src/FamilyNido.Persistence/Migrations/20260421210956_InitialCreate.Designer.cs new file mode 100644 index 0000000..89bb0bd --- /dev/null +++ b/src/FamilyNido.Persistence/Migrations/20260421210956_InitialCreate.Designer.cs @@ -0,0 +1,2750 @@ +// +using System; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FamilyNido.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260421210956_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FamilyNido.Domain.Agenda.MemberAgendaException", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("EndTime") + .HasColumnType("time without time zone") + .HasColumnName("end_time"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("IsAway") + .HasColumnType("boolean") + .HasColumnName("is_away"); + + b.Property("IsCancelled") + .HasColumnType("boolean") + .HasColumnName("is_cancelled"); + + b.Property("Label") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("label"); + + b.Property("Location") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("location"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("notes"); + + b.Property("PatternId") + .HasColumnType("uuid") + .HasColumnName("pattern_id"); + + b.Property("StartTime") + .HasColumnType("time without time zone") + .HasColumnName("start_time"); + + b.Property("TransportMode") + .HasColumnType("integer") + .HasColumnName("transport_mode"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_member_agenda_exceptions"); + + b.HasIndex("PatternId") + .HasDatabaseName("ix_member_agenda_exceptions_pattern_id"); + + b.HasIndex("FamilyId", "Date") + .HasDatabaseName("ix_member_agenda_exceptions_family_id_date"); + + b.HasIndex("FamilyMemberId", "Date", "PatternId") + .IsUnique() + .HasDatabaseName("ix_member_agenda_exceptions_family_member_id_date_pattern_id"); + + b.ToTable("member_agenda_exceptions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Agenda.MemberAgendaPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("DayOfWeek") + .HasColumnType("integer") + .HasColumnName("day_of_week"); + + b.Property("EndTime") + .HasColumnType("time without time zone") + .HasColumnName("end_time"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsAway") + .HasColumnType("boolean") + .HasColumnName("is_away"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("label"); + + b.Property("Location") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("location"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("notes"); + + b.Property("StartTime") + .HasColumnType("time without time zone") + .HasColumnName("start_time"); + + b.Property("TransportMode") + .HasColumnType("integer") + .HasColumnName("transport_mode"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_member_agenda_patterns"); + + b.HasIndex("FamilyId") + .HasDatabaseName("ix_member_agenda_patterns_family_id"); + + b.HasIndex("FamilyMemberId", "DayOfWeek") + .HasDatabaseName("ix_member_agenda_patterns_family_member_id_day_of_week"); + + b.ToTable("member_agenda_patterns", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.CalendarEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_at"); + + b.Property("ExternalEventId") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("external_event_id"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("HtmlLink") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("html_link"); + + b.Property("IcalUid") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("ical_uid"); + + b.Property("IsAllDay") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_all_day"); + + b.Property("LinkedCalendarId") + .HasColumnType("uuid") + .HasColumnName("linked_calendar_id"); + + b.Property("Location") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("location"); + + b.Property("OriginalTimeZone") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("original_time_zone"); + + b.Property("StartAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_at"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_calendar_events"); + + b.HasIndex("FamilyId", "EndAt") + .HasDatabaseName("ix_calendar_events_family_id_end_at"); + + b.HasIndex("FamilyId", "StartAt") + .HasDatabaseName("ix_calendar_events_family_id_start_at"); + + b.HasIndex("LinkedCalendarId", "ExternalEventId") + .IsUnique() + .HasDatabaseName("ix_calendar_events_linked_calendar_id_external_event_id"); + + b.ToTable("calendar_events", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.GoogleAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("encrypted_refresh_token"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_revoked"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_google_accounts"); + + b.HasIndex("FamilyId") + .HasDatabaseName("ix_google_accounts_family_id"); + + b.HasIndex("UserId", "Email") + .IsUnique() + .HasDatabaseName("ix_google_accounts_user_id_email"); + + b.ToTable("google_accounts", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.LinkedCalendar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ColorHex") + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("color_hex"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("ExternalCalendarId") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("external_calendar_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("GoogleAccountId") + .HasColumnType("uuid") + .HasColumnName("google_account_id"); + + b.Property("IsImported") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_imported"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("summary"); + + b.Property("SyncToken") + .HasColumnType("text") + .HasColumnName("sync_token"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_linked_calendars"); + + b.HasIndex("FamilyMemberId") + .HasDatabaseName("ix_linked_calendars_family_member_id"); + + b.HasIndex("GoogleAccountId", "ExternalCalendarId") + .IsUnique() + .HasDatabaseName("ix_linked_calendars_google_account_id_external_calendar_id"); + + b.HasIndex("GoogleAccountId", "IsImported") + .HasDatabaseName("ix_linked_calendars_google_account_id_is_imported"); + + b.ToTable("linked_calendars", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Families.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasColumnName("latitude"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("LocationLabel") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("location_label"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasColumnName("longitude"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("time_zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_families"); + + b.ToTable("families", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Families.FamilyMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BirthDate") + .HasColumnType("date") + .HasColumnName("birth_date"); + + b.Property("ColorHex") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("color_hex"); + + b.Property("ContactEmail") + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("contact_email"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("display_name"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("MemberType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("member_type"); + + b.Property("PhotoPath") + .HasMaxLength(260) + .HasColumnType("character varying(260)") + .HasColumnName("photo_path"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_family_members"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_family_members_user_id") + .HasFilter("user_id IS NOT NULL"); + + b.HasIndex("FamilyId", "IsActive") + .HasDatabaseName("ix_family_members_family_id_is_active"); + + b.ToTable("family_members", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Files.FileAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("content_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("Height") + .HasColumnType("integer") + .HasColumnName("height"); + + b.Property("OwnerMemberId") + .HasColumnType("uuid") + .HasColumnName("owner_member_id"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("relative_path"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("Width") + .HasColumnType("integer") + .HasColumnName("width"); + + b.HasKey("Id") + .HasName("pk_file_assets"); + + b.HasIndex("FamilyId") + .HasDatabaseName("ix_file_assets_family_id"); + + b.HasIndex("OwnerMemberId") + .HasDatabaseName("ix_file_assets_owner_member_id"); + + b.HasIndex("RelativePath") + .IsUnique() + .HasDatabaseName("ix_file_assets_relative_path"); + + b.ToTable("file_assets", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.HealthProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Allergies") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("blood_type"); + + b.Property("ChronicConditions") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("chronic_conditions"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("notes"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_health_profiles"); + + b.HasIndex("FamilyMemberId") + .IsUnique() + .HasDatabaseName("ix_health_profiles_family_member_id"); + + b.ToTable("health_profiles", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.Medication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Dose") + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("dose"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("Frequency") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("frequency"); + + b.Property("Instructions") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("instructions"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_medications"); + + b.HasIndex("FamilyMemberId", "StartDate") + .HasDatabaseName("ix_medications_family_member_id_start_date"); + + b.ToTable("medications", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.Vaccination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.Property("NextDueDate") + .HasColumnType("date") + .HasColumnName("next_due_date"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("notes"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_vaccinations"); + + b.HasIndex("FamilyMemberId", "Date") + .HasDatabaseName("ix_vaccinations_family_member_id_date"); + + b.ToTable("vaccinations", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.HouseholdTasks.HouseholdTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasDefaultValue("General") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid") + .HasColumnName("created_by_member_id"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("DueDate") + .HasColumnType("date") + .HasColumnName("due_date"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("IsArchived") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_archived"); + + b.Property("IsFloating") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_floating"); + + b.Property("MonthlyDay") + .HasColumnType("integer") + .HasColumnName("monthly_day"); + + b.Property("Points") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(5) + .HasColumnName("points"); + + b.Property("Recurrence") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("recurrence"); + + b.Property("ResponsibleMemberId") + .HasColumnType("uuid") + .HasColumnName("responsible_member_id"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("TimeOfDay") + .HasColumnType("time without time zone") + .HasColumnName("time_of_day"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("WeeklyDays") + .HasColumnType("smallint") + .HasColumnName("weekly_days"); + + b.HasKey("Id") + .HasName("pk_household_tasks"); + + b.HasIndex("CreatedByMemberId") + .HasDatabaseName("ix_household_tasks_created_by_member_id"); + + b.HasIndex("FamilyId") + .HasDatabaseName("ix_household_tasks_family_id"); + + b.HasIndex("ResponsibleMemberId") + .HasDatabaseName("ix_household_tasks_responsible_member_id"); + + b.HasIndex("FamilyId", "IsArchived") + .HasDatabaseName("ix_household_tasks_family_id_is_archived"); + + b.ToTable("household_tasks", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.HouseholdTasks.TaskCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CompletedByMemberId") + .HasColumnType("uuid") + .HasColumnName("completed_by_member_id"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("OccurrenceDate") + .HasColumnType("date") + .HasColumnName("occurrence_date"); + + b.Property("TaskId") + .HasColumnType("uuid") + .HasColumnName("task_id"); + + b.HasKey("Id") + .HasName("pk_task_completions"); + + b.HasIndex("CompletedByMemberId") + .HasDatabaseName("ix_task_completions_completed_by_member_id"); + + b.HasIndex("TaskId", "OccurrenceDate") + .IsUnique() + .HasDatabaseName("ix_task_completions_task_id_occurrence_date"); + + b.ToTable("task_completions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("ConsumedByUserId") + .HasColumnType("uuid") + .HasColumnName("consumed_by_user_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RoleOnAccept") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("role_on_accept"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("token_hash"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_invitations"); + + b.HasIndex("FamilyMemberId") + .HasDatabaseName("ix_invitations_family_member_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_invitations_token_hash"); + + b.HasIndex("FamilyId", "ConsumedAt", "RevokedAt") + .HasDatabaseName("ix_invitations_family_id_consumed_at_revoked_at"); + + b.ToTable("invitations", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("LastWallReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_wall_read_at"); + + b.Property("PreferredLanguage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("es-ES") + .HasColumnName("preferred_language"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.UserCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("PasswordHash") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("password_hash"); + + b.Property("Provider") + .HasColumnType("integer") + .HasColumnName("provider"); + + b.Property("ProviderKey") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("provider_key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_credentials"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_credentials_user_id"); + + b.HasIndex("Provider", "ProviderKey") + .IsUnique() + .HasDatabaseName("ix_user_credentials_provider_provider_key") + .HasFilter("provider_key IS NOT NULL"); + + b.ToTable("user_credentials", null, t => + { + t.HasCheckConstraint("ck_user_credentials_shape", "(provider = 0 AND provider_key IS NOT NULL AND password_hash IS NULL) OR (provider = 1 AND provider_key IS NULL AND password_hash IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FamilyNido.Domain.Integrations.IntegrationApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorMemberId") + .HasColumnType("uuid") + .HasColumnName("author_member_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("prefix"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_integration_api_keys"); + + b.HasIndex("AuthorMemberId") + .HasDatabaseName("ix_integration_api_keys_author_member_id"); + + b.HasIndex("FamilyId") + .HasDatabaseName("ix_integration_api_keys_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_integration_api_keys_token_hash"); + + b.ToTable("integration_api_keys", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Meals.MealPlanSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FirstCourse") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("first_course"); + + b.Property("SecondCourse") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("second_course"); + + b.Property("Slot") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("slot"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_meal_plan_slots"); + + b.HasIndex("FamilyId", "FirstCourse") + .HasDatabaseName("ix_meal_plan_slots_family_id_first_course"); + + b.HasIndex("FamilyId", "SecondCourse") + .HasDatabaseName("ix_meal_plan_slots_family_id_second_course"); + + b.HasIndex("FamilyId", "Date", "Slot") + .IsUnique() + .HasDatabaseName("ix_meal_plan_slots_family_id_date_slot"); + + b.ToTable("meal_plan_slots", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.EmailDigestRun", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LocalDate") + .HasColumnType("date") + .HasColumnName("local_date"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.HasKey("UserId", "LocalDate") + .HasName("pk_email_digest_runs"); + + b.ToTable("email_digest_runs", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.UserDashboardPreferences", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WidgetsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("widgets_json"); + + b.HasKey("UserId") + .HasName("pk_user_dashboard_preferences"); + + b.ToTable("user_dashboard_preferences", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.UserNotificationPreferences", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("DigestEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("digest_enabled"); + + b.Property("EmailEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("email_enabled"); + + b.Property("TaskAssignedEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("task_assigned_enabled"); + + b.Property("WallMentionEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("wall_mention_enabled"); + + b.HasKey("UserId") + .HasName("pk_user_notification_preferences"); + + b.ToTable("user_notification_preferences", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.Extracurricular", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContactPhone") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("contact_phone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("DefaultDropoffMemberId") + .HasColumnType("uuid") + .HasColumnName("default_dropoff_member_id"); + + b.Property("DefaultPickupMemberId") + .HasColumnType("uuid") + .HasColumnName("default_pickup_member_id"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("EndTime") + .HasColumnType("time without time zone") + .HasColumnName("end_time"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("IsArchived") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_archived"); + + b.Property("Location") + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("location"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("notes"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("StartTime") + .HasColumnType("time without time zone") + .HasColumnName("start_time"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("WeeklyDays") + .HasColumnType("smallint") + .HasColumnName("weekly_days"); + + b.HasKey("Id") + .HasName("pk_extracurriculars"); + + b.HasIndex("DefaultDropoffMemberId") + .HasDatabaseName("ix_extracurriculars_default_dropoff_member_id"); + + b.HasIndex("DefaultPickupMemberId") + .HasDatabaseName("ix_extracurriculars_default_pickup_member_id"); + + b.HasIndex("FamilyMemberId") + .HasDatabaseName("ix_extracurriculars_family_member_id"); + + b.HasIndex("FamilyId", "IsArchived") + .HasDatabaseName("ix_extracurriculars_family_id_is_archived"); + + b.ToTable("extracurriculars", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.ExtracurricularException", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("DropoffMemberId") + .HasColumnType("uuid") + .HasColumnName("dropoff_member_id"); + + b.Property("ExtracurricularId") + .HasColumnType("uuid") + .HasColumnName("extracurricular_id"); + + b.Property("IsCancelled") + .HasColumnType("boolean") + .HasColumnName("is_cancelled"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("notes"); + + b.Property("PickupMemberId") + .HasColumnType("uuid") + .HasColumnName("pickup_member_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_extracurricular_exceptions"); + + b.HasIndex("DropoffMemberId") + .HasDatabaseName("ix_extracurricular_exceptions_dropoff_member_id"); + + b.HasIndex("PickupMemberId") + .HasDatabaseName("ix_extracurricular_exceptions_pickup_member_id"); + + b.HasIndex("ExtracurricularId", "Date") + .IsUnique() + .HasDatabaseName("ix_extracurricular_exceptions_extracurricular_id_date"); + + b.ToTable("extracurricular_exceptions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolDayException", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AfternoonTime") + .HasColumnType("time without time zone") + .HasColumnName("afternoon_time"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("DropoffMemberId") + .HasColumnType("uuid") + .HasColumnName("dropoff_member_id"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("IsCancelled") + .HasColumnType("boolean") + .HasColumnName("is_cancelled"); + + b.Property("MorningTime") + .HasColumnType("time without time zone") + .HasColumnName("morning_time"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("notes"); + + b.Property("PickupMemberId") + .HasColumnType("uuid") + .HasColumnName("pickup_member_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_school_day_exceptions"); + + b.HasIndex("DropoffMemberId") + .HasDatabaseName("ix_school_day_exceptions_dropoff_member_id"); + + b.HasIndex("PickupMemberId") + .HasDatabaseName("ix_school_day_exceptions_pickup_member_id"); + + b.HasIndex("FamilyId", "Date") + .HasDatabaseName("ix_school_day_exceptions_family_id_date"); + + b.HasIndex("FamilyMemberId", "Date") + .IsUnique() + .HasDatabaseName("ix_school_day_exceptions_family_member_id_date"); + + b.ToTable("school_day_exceptions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolDaySchedule", b => + { + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("DayOfWeek") + .HasColumnType("integer") + .HasColumnName("day_of_week"); + + b.Property("DropoffMemberId") + .HasColumnType("uuid") + .HasColumnName("dropoff_member_id"); + + b.Property("PickupMemberId") + .HasColumnType("uuid") + .HasColumnName("pickup_member_id"); + + b.HasKey("FamilyMemberId", "DayOfWeek") + .HasName("pk_school_day_schedules"); + + b.HasIndex("DropoffMemberId") + .HasDatabaseName("ix_school_day_schedules_dropoff_member_id"); + + b.HasIndex("PickupMemberId") + .HasDatabaseName("ix_school_day_schedules_pickup_member_id"); + + b.ToTable("school_day_schedules", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("label"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_school_holidays"); + + b.HasIndex("FamilyId", "StartDate") + .HasDatabaseName("ix_school_holidays_family_id_start_date"); + + b.ToTable("school_holidays", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AfternoonTime") + .HasColumnType("time without time zone") + .HasColumnName("afternoon_time"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("Grade") + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("grade"); + + b.Property("MorningTime") + .HasColumnType("time without time zone") + .HasColumnName("morning_time"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("notes"); + + b.Property("SchoolName") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("school_name"); + + b.Property("TransportMode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("transport_mode"); + + b.Property("Tutor") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("tutor"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_school_profiles"); + + b.HasIndex("FamilyMemberId") + .IsUnique() + .HasDatabaseName("ix_school_profiles_family_member_id"); + + b.ToTable("school_profiles", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorMemberId") + .HasColumnType("uuid") + .HasColumnName("author_member_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("text"); + + b.Property("TextHtml") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("text_html"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_wall_comments"); + + b.HasIndex("AuthorMemberId") + .HasDatabaseName("ix_wall_comments_author_member_id"); + + b.HasIndex("MessageId", "CreatedAt") + .HasDatabaseName("ix_wall_comments_message_id_created_at"); + + b.ToTable("wall_comments", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallCommentMention", b => + { + b.Property("CommentId") + .HasColumnType("uuid") + .HasColumnName("comment_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.HasKey("CommentId", "FamilyMemberId") + .HasName("pk_wall_comment_mentions"); + + b.HasIndex("FamilyMemberId") + .HasDatabaseName("ix_wall_comment_mentions_family_member_id"); + + b.ToTable("wall_comment_mentions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorMemberId") + .HasColumnType("uuid") + .HasColumnName("author_member_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("ImageFileId") + .HasColumnType("uuid") + .HasColumnName("image_file_id"); + + b.Property("IsPinned") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_pinned"); + + b.Property("PinnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("pinned_at"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("text"); + + b.Property("TextHtml") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("text_html"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_wall_messages"); + + b.HasIndex("AuthorMemberId") + .HasDatabaseName("ix_wall_messages_author_member_id"); + + b.HasIndex("ImageFileId") + .HasDatabaseName("ix_wall_messages_image_file_id"); + + b.HasIndex("FamilyId", "CreatedAt") + .HasDatabaseName("ix_wall_messages_family_id_created_at"); + + b.HasIndex("FamilyId", "IsPinned", "PinnedAt") + .HasDatabaseName("ix_wall_messages_family_id_is_pinned_pinned_at"); + + b.ToTable("wall_messages", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallMessageMention", b => + { + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.HasKey("MessageId", "FamilyMemberId") + .HasName("pk_wall_message_mentions"); + + b.HasIndex("FamilyMemberId") + .HasDatabaseName("ix_wall_message_mentions_family_member_id"); + + b.ToTable("wall_message_mentions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Emoji") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("MemberId") + .HasColumnType("uuid") + .HasColumnName("member_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("ReactedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reacted_at"); + + b.HasKey("Id") + .HasName("pk_wall_reactions"); + + b.HasIndex("MemberId") + .HasDatabaseName("ix_wall_reactions_member_id"); + + b.HasIndex("MessageId", "MemberId", "Emoji") + .IsUnique() + .HasDatabaseName("ix_wall_reactions_message_id_member_id_emoji"); + + b.ToTable("wall_reactions", (string)null); + }); + + modelBuilder.Entity("calendar_event_members", b => + { + b.Property("calendar_event_id") + .HasColumnType("uuid") + .HasColumnName("calendar_event_id"); + + b.Property("family_member_id") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.HasKey("calendar_event_id", "family_member_id") + .HasName("pk_calendar_event_members"); + + b.HasIndex("family_member_id") + .HasDatabaseName("ix_calendar_event_members_family_member_id"); + + b.ToTable("calendar_event_members", (string)null); + }); + + modelBuilder.Entity("household_task_related_members", b => + { + b.Property("task_id") + .HasColumnType("uuid") + .HasColumnName("task_id"); + + b.Property("family_member_id") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.HasKey("task_id", "family_member_id") + .HasName("pk_household_task_related_members"); + + b.HasIndex("family_member_id") + .HasDatabaseName("ix_household_task_related_members_family_member_id"); + + b.ToTable("household_task_related_members", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Agenda.MemberAgendaException", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_member_agenda_exceptions_family_members_family_member_id"); + + b.HasOne("FamilyNido.Domain.Agenda.MemberAgendaPattern", "Pattern") + .WithMany() + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_member_agenda_exceptions_member_agenda_patterns_pattern_id"); + + b.Navigation("FamilyMember"); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Agenda.MemberAgendaPattern", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_member_agenda_patterns_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.CalendarEvent", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_calendar_events_families_family_id"); + + b.HasOne("FamilyNido.Domain.Calendar.LinkedCalendar", "LinkedCalendar") + .WithMany("Events") + .HasForeignKey("LinkedCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_calendar_events_linked_calendars_linked_calendar_id"); + + b.Navigation("Family"); + + b.Navigation("LinkedCalendar"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.GoogleAccount", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_google_accounts_families_family_id"); + + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_google_accounts_users_user_id"); + + b.Navigation("Family"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.LinkedCalendar", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_linked_calendars_family_members_family_member_id"); + + b.HasOne("FamilyNido.Domain.Calendar.GoogleAccount", "GoogleAccount") + .WithMany("Calendars") + .HasForeignKey("GoogleAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_linked_calendars_google_accounts_google_account_id"); + + b.Navigation("FamilyMember"); + + b.Navigation("GoogleAccount"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Families.FamilyMember", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany("Members") + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_family_members_families_family_id"); + + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithOne("FamilyMember") + .HasForeignKey("FamilyNido.Domain.Families.FamilyMember", "UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_family_members_users_user_id"); + + b.Navigation("Family"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Files.FileAsset", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_file_assets_families_family_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "OwnerMember") + .WithMany() + .HasForeignKey("OwnerMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_file_assets_family_members_owner_member_id"); + + b.Navigation("Family"); + + b.Navigation("OwnerMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.HealthProfile", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithOne() + .HasForeignKey("FamilyNido.Domain.Health.HealthProfile", "FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_health_profiles_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.Medication", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_medications_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.Vaccination", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vaccinations_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.HouseholdTasks.HouseholdTask", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "CreatedByMember") + .WithMany() + .HasForeignKey("CreatedByMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_household_tasks_family_members_created_by_member_id"); + + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_household_tasks_families_family_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "ResponsibleMember") + .WithMany() + .HasForeignKey("ResponsibleMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_household_tasks_family_members_responsible_member_id"); + + b.Navigation("CreatedByMember"); + + b.Navigation("Family"); + + b.Navigation("ResponsibleMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.HouseholdTasks.TaskCompletion", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "CompletedBy") + .WithMany() + .HasForeignKey("CompletedByMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_task_completions_family_members_completed_by_member_id"); + + b.HasOne("FamilyNido.Domain.HouseholdTasks.HouseholdTask", "Task") + .WithMany("Completions") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_task_completions_household_tasks_task_id"); + + b.Navigation("CompletedBy"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.Invitation", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_invitations_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.UserCredential", b => + { + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithMany("Credentials") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_credentials_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Integrations.IntegrationApiKey", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "AuthorMember") + .WithMany() + .HasForeignKey("AuthorMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_integration_api_keys_family_members_author_member_id"); + + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_integration_api_keys_families_family_id"); + + b.Navigation("AuthorMember"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Meals.MealPlanSlot", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_meal_plan_slots_families_family_id"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.EmailDigestRun", b => + { + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_email_digest_runs_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.UserDashboardPreferences", b => + { + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithOne() + .HasForeignKey("FamilyNido.Domain.Notifications.UserDashboardPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_dashboard_preferences_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.UserNotificationPreferences", b => + { + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithOne() + .HasForeignKey("FamilyNido.Domain.Notifications.UserNotificationPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_notification_preferences_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.Extracurricular", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "DefaultDropoffMember") + .WithMany() + .HasForeignKey("DefaultDropoffMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_extracurriculars_family_members_default_dropoff_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "DefaultPickupMember") + .WithMany() + .HasForeignKey("DefaultPickupMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_extracurriculars_family_members_default_pickup_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_extracurriculars_family_members_family_member_id"); + + b.Navigation("DefaultDropoffMember"); + + b.Navigation("DefaultPickupMember"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.ExtracurricularException", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "DropoffMember") + .WithMany() + .HasForeignKey("DropoffMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_extracurricular_exceptions_family_members_dropoff_member_id"); + + b.HasOne("FamilyNido.Domain.School.Extracurricular", "Extracurricular") + .WithMany("Exceptions") + .HasForeignKey("ExtracurricularId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_extracurricular_exceptions_extracurriculars_extracurricular"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "PickupMember") + .WithMany() + .HasForeignKey("PickupMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_extracurricular_exceptions_family_members_pickup_member_id"); + + b.Navigation("DropoffMember"); + + b.Navigation("Extracurricular"); + + b.Navigation("PickupMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolDayException", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "DropoffMember") + .WithMany() + .HasForeignKey("DropoffMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_school_day_exceptions_family_members_dropoff_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_school_day_exceptions_family_members_family_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "PickupMember") + .WithMany() + .HasForeignKey("PickupMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_school_day_exceptions_family_members_pickup_member_id"); + + b.Navigation("DropoffMember"); + + b.Navigation("FamilyMember"); + + b.Navigation("PickupMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolDaySchedule", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "DropoffMember") + .WithMany() + .HasForeignKey("DropoffMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_school_day_schedules_family_members_dropoff_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_school_day_schedules_family_members_family_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "PickupMember") + .WithMany() + .HasForeignKey("PickupMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_school_day_schedules_family_members_pickup_member_id"); + + b.Navigation("DropoffMember"); + + b.Navigation("FamilyMember"); + + b.Navigation("PickupMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolHoliday", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_school_holidays_families_family_id"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolProfile", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithOne() + .HasForeignKey("FamilyNido.Domain.School.SchoolProfile", "FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_school_profiles_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallComment", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "AuthorMember") + .WithMany() + .HasForeignKey("AuthorMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_wall_comments_family_members_author_member_id"); + + b.HasOne("FamilyNido.Domain.Wall.WallMessage", "Message") + .WithMany("Comments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_comments_wall_messages_message_id"); + + b.Navigation("AuthorMember"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallCommentMention", b => + { + b.HasOne("FamilyNido.Domain.Wall.WallComment", "Comment") + .WithMany("Mentions") + .HasForeignKey("CommentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_comment_mentions_wall_comments_comment_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_comment_mentions_family_members_family_member_id"); + + b.Navigation("Comment"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallMessage", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "AuthorMember") + .WithMany() + .HasForeignKey("AuthorMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_wall_messages_family_members_author_member_id"); + + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_wall_messages_families_family_id"); + + b.HasOne("FamilyNido.Domain.Files.FileAsset", "ImageFile") + .WithMany() + .HasForeignKey("ImageFileId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_wall_messages_file_assets_image_file_id"); + + b.Navigation("AuthorMember"); + + b.Navigation("Family"); + + b.Navigation("ImageFile"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallMessageMention", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_message_mentions_family_members_family_member_id"); + + b.HasOne("FamilyNido.Domain.Wall.WallMessage", "Message") + .WithMany("Mentions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_message_mentions_wall_messages_message_id"); + + b.Navigation("FamilyMember"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallReaction", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_reactions_family_members_member_id"); + + b.HasOne("FamilyNido.Domain.Wall.WallMessage", "Message") + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_reactions_wall_messages_message_id"); + + b.Navigation("Member"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("calendar_event_members", b => + { + b.HasOne("FamilyNido.Domain.Calendar.CalendarEvent", null) + .WithMany() + .HasForeignKey("calendar_event_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_calendar_event_members_calendar_events_calendar_event_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", null) + .WithMany() + .HasForeignKey("family_member_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_calendar_event_members_family_members_family_member_id"); + }); + + modelBuilder.Entity("household_task_related_members", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", null) + .WithMany() + .HasForeignKey("family_member_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_household_task_related_members_family_members_family_member"); + + b.HasOne("FamilyNido.Domain.HouseholdTasks.HouseholdTask", null) + .WithMany() + .HasForeignKey("task_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_household_task_related_members_household_tasks_task_id"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.GoogleAccount", b => + { + b.Navigation("Calendars"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.LinkedCalendar", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Families.Family", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("FamilyNido.Domain.HouseholdTasks.HouseholdTask", b => + { + b.Navigation("Completions"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.User", b => + { + b.Navigation("Credentials"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.Extracurricular", b => + { + b.Navigation("Exceptions"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallComment", b => + { + b.Navigation("Mentions"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallMessage", b => + { + b.Navigation("Comments"); + + b.Navigation("Mentions"); + + b.Navigation("Reactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FamilyNido.Persistence/Migrations/20260421210956_InitialCreate.cs b/src/FamilyNido.Persistence/Migrations/20260421210956_InitialCreate.cs new file mode 100644 index 0000000..9c76e10 --- /dev/null +++ b/src/FamilyNido.Persistence/Migrations/20260421210956_InitialCreate.cs @@ -0,0 +1,1501 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FamilyNido.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "families", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + time_zone = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + locale = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + latitude = table.Column(type: "double precision", nullable: true), + longitude = table.Column(type: "double precision", nullable: true), + location_label = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_families", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "character varying(254)", maxLength: 254, nullable: false), + display_name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + role = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + last_login_at = table.Column(type: "timestamp with time zone", nullable: true), + last_wall_read_at = table.Column(type: "timestamp with time zone", nullable: true), + preferred_language = table.Column(type: "character varying(16)", maxLength: 16, nullable: false, defaultValue: "es-ES"), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_users", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "meal_plan_slots", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + date = table.Column(type: "date", nullable: false), + slot = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + first_course = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + second_course = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_meal_plan_slots", x => x.id); + table.ForeignKey( + name: "fk_meal_plan_slots_families_family_id", + column: x => x.family_id, + principalTable: "families", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "school_holidays", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + start_date = table.Column(type: "date", nullable: false), + end_date = table.Column(type: "date", nullable: false), + label = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_school_holidays", x => x.id); + table.ForeignKey( + name: "fk_school_holidays_families_family_id", + column: x => x.family_id, + principalTable: "families", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "email_digest_runs", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + local_date = table.Column(type: "date", nullable: false), + sent_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_email_digest_runs", x => new { x.user_id, x.local_date }); + table.ForeignKey( + name: "fk_email_digest_runs_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "family_members", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + display_name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + birth_date = table.Column(type: "date", nullable: true), + member_type = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + photo_path = table.Column(type: "character varying(260)", maxLength: 260, nullable: true), + color_hex = table.Column(type: "character varying(7)", maxLength: 7, nullable: false), + contact_email = table.Column(type: "character varying(254)", maxLength: 254, nullable: true), + user_id = table.Column(type: "uuid", nullable: true), + is_active = table.Column(type: "boolean", nullable: false, defaultValue: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_family_members", x => x.id); + table.ForeignKey( + name: "fk_family_members_families_family_id", + column: x => x.family_id, + principalTable: "families", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_family_members_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "google_accounts", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "character varying(320)", maxLength: 320, nullable: false), + display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + encrypted_refresh_token = table.Column(type: "text", nullable: false), + last_error = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + is_revoked = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_google_accounts", x => x.id); + table.ForeignKey( + name: "fk_google_accounts_families_family_id", + column: x => x.family_id, + principalTable: "families", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_google_accounts_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_credentials", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + provider = table.Column(type: "integer", nullable: false), + provider_key = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + password_hash = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + last_used_at = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user_credentials", x => x.id); + table.CheckConstraint("ck_user_credentials_shape", "(provider = 0 AND provider_key IS NOT NULL AND password_hash IS NULL) OR (provider = 1 AND provider_key IS NULL AND password_hash IS NOT NULL)"); + table.ForeignKey( + name: "fk_user_credentials_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_dashboard_preferences", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + widgets_json = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_dashboard_preferences", x => x.user_id); + table.ForeignKey( + name: "fk_user_dashboard_preferences_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_notification_preferences", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + email_enabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + digest_enabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + task_assigned_enabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + wall_mention_enabled = table.Column(type: "boolean", nullable: false, defaultValue: true) + }, + constraints: table => + { + table.PrimaryKey("pk_user_notification_preferences", x => x.user_id); + table.ForeignKey( + name: "fk_user_notification_preferences_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "extracurriculars", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + location = table.Column(type: "character varying(160)", maxLength: 160, nullable: true), + contact_phone = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + weekly_days = table.Column(type: "smallint", nullable: false), + start_time = table.Column(type: "time without time zone", nullable: false), + end_time = table.Column(type: "time without time zone", nullable: false), + start_date = table.Column(type: "date", nullable: false), + end_date = table.Column(type: "date", nullable: true), + default_dropoff_member_id = table.Column(type: "uuid", nullable: true), + default_pickup_member_id = table.Column(type: "uuid", nullable: true), + notes = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + is_archived = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_extracurriculars", x => x.id); + table.ForeignKey( + name: "fk_extracurriculars_family_members_default_dropoff_member_id", + column: x => x.default_dropoff_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_extracurriculars_family_members_default_pickup_member_id", + column: x => x.default_pickup_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_extracurriculars_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "file_assets", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + owner_member_id = table.Column(type: "uuid", nullable: false), + relative_path = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + content_type = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + size_bytes = table.Column(type: "bigint", nullable: false), + width = table.Column(type: "integer", nullable: true), + height = table.Column(type: "integer", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_file_assets", x => x.id); + table.ForeignKey( + name: "fk_file_assets_families_family_id", + column: x => x.family_id, + principalTable: "families", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_file_assets_family_members_owner_member_id", + column: x => x.owner_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "health_profiles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false), + blood_type = table.Column(type: "character varying(8)", maxLength: 8, nullable: true), + allergies = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + chronic_conditions = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + notes = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_health_profiles", x => x.id); + table.ForeignKey( + name: "fk_health_profiles_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "household_tasks", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + title = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + category = table.Column(type: "character varying(40)", maxLength: 40, nullable: false, defaultValue: "General"), + recurrence = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + weekly_days = table.Column(type: "smallint", nullable: true), + monthly_day = table.Column(type: "integer", nullable: true), + time_of_day = table.Column(type: "time without time zone", nullable: true), + start_date = table.Column(type: "date", nullable: false), + due_date = table.Column(type: "date", nullable: true), + points = table.Column(type: "integer", nullable: false, defaultValue: 5), + is_archived = table.Column(type: "boolean", nullable: false, defaultValue: false), + is_floating = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_by_member_id = table.Column(type: "uuid", nullable: false), + responsible_member_id = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_household_tasks", x => x.id); + table.ForeignKey( + name: "fk_household_tasks_families_family_id", + column: x => x.family_id, + principalTable: "families", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_household_tasks_family_members_created_by_member_id", + column: x => x.created_by_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_household_tasks_family_members_responsible_member_id", + column: x => x.responsible_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "integration_api_keys", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + author_member_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + prefix = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + last_used_at = table.Column(type: "timestamp with time zone", nullable: true), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_integration_api_keys", x => x.id); + table.ForeignKey( + name: "fk_integration_api_keys_families_family_id", + column: x => x.family_id, + principalTable: "families", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_integration_api_keys_family_members_author_member_id", + column: x => x.author_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "invitations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "character varying(254)", maxLength: 254, nullable: false), + role_on_accept = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + token_hash = table.Column(type: "bytea", nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + consumed_at = table.Column(type: "timestamp with time zone", nullable: true), + consumed_by_user_id = table.Column(type: "uuid", nullable: true), + revoked_at = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_invitations", x => x.id); + table.ForeignKey( + name: "fk_invitations_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "medications", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + dose = table.Column(type: "character varying(80)", maxLength: 80, nullable: true), + frequency = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + start_date = table.Column(type: "date", nullable: false), + end_date = table.Column(type: "date", nullable: true), + instructions = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_medications", x => x.id); + table.ForeignKey( + name: "fk_medications_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "member_agenda_patterns", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false), + day_of_week = table.Column(type: "integer", nullable: false), + label = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + location = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + start_time = table.Column(type: "time without time zone", nullable: true), + end_time = table.Column(type: "time without time zone", nullable: true), + transport_mode = table.Column(type: "integer", nullable: false), + is_away = table.Column(type: "boolean", nullable: false), + notes = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + is_active = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_member_agenda_patterns", x => x.id); + table.ForeignKey( + name: "fk_member_agenda_patterns_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "school_day_exceptions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false), + date = table.Column(type: "date", nullable: false), + is_cancelled = table.Column(type: "boolean", nullable: false), + dropoff_member_id = table.Column(type: "uuid", nullable: true), + pickup_member_id = table.Column(type: "uuid", nullable: true), + morning_time = table.Column(type: "time without time zone", nullable: true), + afternoon_time = table.Column(type: "time without time zone", nullable: true), + notes = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_school_day_exceptions", x => x.id); + table.ForeignKey( + name: "fk_school_day_exceptions_family_members_dropoff_member_id", + column: x => x.dropoff_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_school_day_exceptions_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_school_day_exceptions_family_members_pickup_member_id", + column: x => x.pickup_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "school_day_schedules", + columns: table => new + { + family_member_id = table.Column(type: "uuid", nullable: false), + day_of_week = table.Column(type: "integer", nullable: false), + dropoff_member_id = table.Column(type: "uuid", nullable: true), + pickup_member_id = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_school_day_schedules", x => new { x.family_member_id, x.day_of_week }); + table.ForeignKey( + name: "fk_school_day_schedules_family_members_dropoff_member_id", + column: x => x.dropoff_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_school_day_schedules_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_school_day_schedules_family_members_pickup_member_id", + column: x => x.pickup_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "school_profiles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false), + school_name = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + grade = table.Column(type: "character varying(60)", maxLength: 60, nullable: true), + tutor = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + transport_mode = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + morning_time = table.Column(type: "time without time zone", nullable: true), + afternoon_time = table.Column(type: "time without time zone", nullable: true), + notes = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_school_profiles", x => x.id); + table.ForeignKey( + name: "fk_school_profiles_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "vaccinations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + date = table.Column(type: "date", nullable: false), + next_due_date = table.Column(type: "date", nullable: true), + notes = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_vaccinations", x => x.id); + table.ForeignKey( + name: "fk_vaccinations_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "linked_calendars", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + google_account_id = table.Column(type: "uuid", nullable: false), + external_calendar_id = table.Column(type: "character varying(320)", maxLength: 320, nullable: false), + summary = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + color_hex = table.Column(type: "character varying(7)", maxLength: 7, nullable: true), + is_imported = table.Column(type: "boolean", nullable: false, defaultValue: false), + family_member_id = table.Column(type: "uuid", nullable: true), + sync_token = table.Column(type: "text", nullable: true), + last_synced_at = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_linked_calendars", x => x.id); + table.ForeignKey( + name: "fk_linked_calendars_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_linked_calendars_google_accounts_google_account_id", + column: x => x.google_account_id, + principalTable: "google_accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "extracurricular_exceptions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + extracurricular_id = table.Column(type: "uuid", nullable: false), + date = table.Column(type: "date", nullable: false), + is_cancelled = table.Column(type: "boolean", nullable: false), + dropoff_member_id = table.Column(type: "uuid", nullable: true), + pickup_member_id = table.Column(type: "uuid", nullable: true), + notes = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_extracurricular_exceptions", x => x.id); + table.ForeignKey( + name: "fk_extracurricular_exceptions_extracurriculars_extracurricular", + column: x => x.extracurricular_id, + principalTable: "extracurriculars", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_extracurricular_exceptions_family_members_dropoff_member_id", + column: x => x.dropoff_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_extracurricular_exceptions_family_members_pickup_member_id", + column: x => x.pickup_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "wall_messages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + author_member_id = table.Column(type: "uuid", nullable: false), + text = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + text_html = table.Column(type: "character varying(8000)", maxLength: 8000, nullable: false), + image_file_id = table.Column(type: "uuid", nullable: true), + is_pinned = table.Column(type: "boolean", nullable: false, defaultValue: false), + pinned_at = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_wall_messages", x => x.id); + table.ForeignKey( + name: "fk_wall_messages_families_family_id", + column: x => x.family_id, + principalTable: "families", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_wall_messages_family_members_author_member_id", + column: x => x.author_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_wall_messages_file_assets_image_file_id", + column: x => x.image_file_id, + principalTable: "file_assets", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "household_task_related_members", + columns: table => new + { + task_id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_household_task_related_members", x => new { x.task_id, x.family_member_id }); + table.ForeignKey( + name: "fk_household_task_related_members_family_members_family_member", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_household_task_related_members_household_tasks_task_id", + column: x => x.task_id, + principalTable: "household_tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "task_completions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + task_id = table.Column(type: "uuid", nullable: false), + occurrence_date = table.Column(type: "date", nullable: false), + completed_by_member_id = table.Column(type: "uuid", nullable: true), + completed_at = table.Column(type: "timestamp with time zone", nullable: false), + note = table.Column(type: "character varying(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_task_completions", x => x.id); + table.ForeignKey( + name: "fk_task_completions_family_members_completed_by_member_id", + column: x => x.completed_by_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_task_completions_household_tasks_task_id", + column: x => x.task_id, + principalTable: "household_tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "member_agenda_exceptions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false), + date = table.Column(type: "date", nullable: false), + pattern_id = table.Column(type: "uuid", nullable: true), + is_cancelled = table.Column(type: "boolean", nullable: false), + label = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + location = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + start_time = table.Column(type: "time without time zone", nullable: true), + end_time = table.Column(type: "time without time zone", nullable: true), + transport_mode = table.Column(type: "integer", nullable: true), + is_away = table.Column(type: "boolean", nullable: true), + notes = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_member_agenda_exceptions", x => x.id); + table.ForeignKey( + name: "fk_member_agenda_exceptions_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_member_agenda_exceptions_member_agenda_patterns_pattern_id", + column: x => x.pattern_id, + principalTable: "member_agenda_patterns", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "calendar_events", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + family_id = table.Column(type: "uuid", nullable: false), + linked_calendar_id = table.Column(type: "uuid", nullable: false), + external_event_id = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + ical_uid = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + title = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + description = table.Column(type: "text", nullable: true), + location = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + start_at = table.Column(type: "timestamp with time zone", nullable: false), + end_at = table.Column(type: "timestamp with time zone", nullable: false), + is_all_day = table.Column(type: "boolean", nullable: false, defaultValue: false), + original_time_zone = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + html_link = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_calendar_events", x => x.id); + table.ForeignKey( + name: "fk_calendar_events_families_family_id", + column: x => x.family_id, + principalTable: "families", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_calendar_events_linked_calendars_linked_calendar_id", + column: x => x.linked_calendar_id, + principalTable: "linked_calendars", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "wall_comments", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + message_id = table.Column(type: "uuid", nullable: false), + author_member_id = table.Column(type: "uuid", nullable: false), + text = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + text_html = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(200)", maxLength: 200, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_wall_comments", x => x.id); + table.ForeignKey( + name: "fk_wall_comments_family_members_author_member_id", + column: x => x.author_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_wall_comments_wall_messages_message_id", + column: x => x.message_id, + principalTable: "wall_messages", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "wall_message_mentions", + columns: table => new + { + message_id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_wall_message_mentions", x => new { x.message_id, x.family_member_id }); + table.ForeignKey( + name: "fk_wall_message_mentions_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_wall_message_mentions_wall_messages_message_id", + column: x => x.message_id, + principalTable: "wall_messages", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "wall_reactions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + message_id = table.Column(type: "uuid", nullable: false), + member_id = table.Column(type: "uuid", nullable: false), + emoji = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + reacted_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_wall_reactions", x => x.id); + table.ForeignKey( + name: "fk_wall_reactions_family_members_member_id", + column: x => x.member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_wall_reactions_wall_messages_message_id", + column: x => x.message_id, + principalTable: "wall_messages", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "calendar_event_members", + columns: table => new + { + calendar_event_id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_calendar_event_members", x => new { x.calendar_event_id, x.family_member_id }); + table.ForeignKey( + name: "fk_calendar_event_members_calendar_events_calendar_event_id", + column: x => x.calendar_event_id, + principalTable: "calendar_events", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_calendar_event_members_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "wall_comment_mentions", + columns: table => new + { + comment_id = table.Column(type: "uuid", nullable: false), + family_member_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_wall_comment_mentions", x => new { x.comment_id, x.family_member_id }); + table.ForeignKey( + name: "fk_wall_comment_mentions_family_members_family_member_id", + column: x => x.family_member_id, + principalTable: "family_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_wall_comment_mentions_wall_comments_comment_id", + column: x => x.comment_id, + principalTable: "wall_comments", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_calendar_event_members_family_member_id", + table: "calendar_event_members", + column: "family_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_calendar_events_family_id_end_at", + table: "calendar_events", + columns: new[] { "family_id", "end_at" }); + + migrationBuilder.CreateIndex( + name: "ix_calendar_events_family_id_start_at", + table: "calendar_events", + columns: new[] { "family_id", "start_at" }); + + migrationBuilder.CreateIndex( + name: "ix_calendar_events_linked_calendar_id_external_event_id", + table: "calendar_events", + columns: new[] { "linked_calendar_id", "external_event_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_extracurricular_exceptions_dropoff_member_id", + table: "extracurricular_exceptions", + column: "dropoff_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_extracurricular_exceptions_extracurricular_id_date", + table: "extracurricular_exceptions", + columns: new[] { "extracurricular_id", "date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_extracurricular_exceptions_pickup_member_id", + table: "extracurricular_exceptions", + column: "pickup_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_extracurriculars_default_dropoff_member_id", + table: "extracurriculars", + column: "default_dropoff_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_extracurriculars_default_pickup_member_id", + table: "extracurriculars", + column: "default_pickup_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_extracurriculars_family_id_is_archived", + table: "extracurriculars", + columns: new[] { "family_id", "is_archived" }); + + migrationBuilder.CreateIndex( + name: "ix_extracurriculars_family_member_id", + table: "extracurriculars", + column: "family_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_family_members_family_id_is_active", + table: "family_members", + columns: new[] { "family_id", "is_active" }); + + migrationBuilder.CreateIndex( + name: "ix_family_members_user_id", + table: "family_members", + column: "user_id", + unique: true, + filter: "user_id IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "ix_file_assets_family_id", + table: "file_assets", + column: "family_id"); + + migrationBuilder.CreateIndex( + name: "ix_file_assets_owner_member_id", + table: "file_assets", + column: "owner_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_file_assets_relative_path", + table: "file_assets", + column: "relative_path", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_google_accounts_family_id", + table: "google_accounts", + column: "family_id"); + + migrationBuilder.CreateIndex( + name: "ix_google_accounts_user_id_email", + table: "google_accounts", + columns: new[] { "user_id", "email" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_health_profiles_family_member_id", + table: "health_profiles", + column: "family_member_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_household_task_related_members_family_member_id", + table: "household_task_related_members", + column: "family_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_household_tasks_created_by_member_id", + table: "household_tasks", + column: "created_by_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_household_tasks_family_id", + table: "household_tasks", + column: "family_id"); + + migrationBuilder.CreateIndex( + name: "ix_household_tasks_family_id_is_archived", + table: "household_tasks", + columns: new[] { "family_id", "is_archived" }); + + migrationBuilder.CreateIndex( + name: "ix_household_tasks_responsible_member_id", + table: "household_tasks", + column: "responsible_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_integration_api_keys_author_member_id", + table: "integration_api_keys", + column: "author_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_integration_api_keys_family_id", + table: "integration_api_keys", + column: "family_id"); + + migrationBuilder.CreateIndex( + name: "ix_integration_api_keys_token_hash", + table: "integration_api_keys", + column: "token_hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_invitations_family_id_consumed_at_revoked_at", + table: "invitations", + columns: new[] { "family_id", "consumed_at", "revoked_at" }); + + migrationBuilder.CreateIndex( + name: "ix_invitations_family_member_id", + table: "invitations", + column: "family_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_invitations_token_hash", + table: "invitations", + column: "token_hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_linked_calendars_family_member_id", + table: "linked_calendars", + column: "family_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_linked_calendars_google_account_id_external_calendar_id", + table: "linked_calendars", + columns: new[] { "google_account_id", "external_calendar_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_linked_calendars_google_account_id_is_imported", + table: "linked_calendars", + columns: new[] { "google_account_id", "is_imported" }); + + migrationBuilder.CreateIndex( + name: "ix_meal_plan_slots_family_id_date_slot", + table: "meal_plan_slots", + columns: new[] { "family_id", "date", "slot" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_meal_plan_slots_family_id_first_course", + table: "meal_plan_slots", + columns: new[] { "family_id", "first_course" }); + + migrationBuilder.CreateIndex( + name: "ix_meal_plan_slots_family_id_second_course", + table: "meal_plan_slots", + columns: new[] { "family_id", "second_course" }); + + migrationBuilder.CreateIndex( + name: "ix_medications_family_member_id_start_date", + table: "medications", + columns: new[] { "family_member_id", "start_date" }); + + migrationBuilder.CreateIndex( + name: "ix_member_agenda_exceptions_family_id_date", + table: "member_agenda_exceptions", + columns: new[] { "family_id", "date" }); + + migrationBuilder.CreateIndex( + name: "ix_member_agenda_exceptions_family_member_id_date_pattern_id", + table: "member_agenda_exceptions", + columns: new[] { "family_member_id", "date", "pattern_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_member_agenda_exceptions_pattern_id", + table: "member_agenda_exceptions", + column: "pattern_id"); + + migrationBuilder.CreateIndex( + name: "ix_member_agenda_patterns_family_id", + table: "member_agenda_patterns", + column: "family_id"); + + migrationBuilder.CreateIndex( + name: "ix_member_agenda_patterns_family_member_id_day_of_week", + table: "member_agenda_patterns", + columns: new[] { "family_member_id", "day_of_week" }); + + migrationBuilder.CreateIndex( + name: "ix_school_day_exceptions_dropoff_member_id", + table: "school_day_exceptions", + column: "dropoff_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_school_day_exceptions_family_id_date", + table: "school_day_exceptions", + columns: new[] { "family_id", "date" }); + + migrationBuilder.CreateIndex( + name: "ix_school_day_exceptions_family_member_id_date", + table: "school_day_exceptions", + columns: new[] { "family_member_id", "date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_school_day_exceptions_pickup_member_id", + table: "school_day_exceptions", + column: "pickup_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_school_day_schedules_dropoff_member_id", + table: "school_day_schedules", + column: "dropoff_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_school_day_schedules_pickup_member_id", + table: "school_day_schedules", + column: "pickup_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_school_holidays_family_id_start_date", + table: "school_holidays", + columns: new[] { "family_id", "start_date" }); + + migrationBuilder.CreateIndex( + name: "ix_school_profiles_family_member_id", + table: "school_profiles", + column: "family_member_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_task_completions_completed_by_member_id", + table: "task_completions", + column: "completed_by_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_task_completions_task_id_occurrence_date", + table: "task_completions", + columns: new[] { "task_id", "occurrence_date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_user_credentials_provider_provider_key", + table: "user_credentials", + columns: new[] { "provider", "provider_key" }, + unique: true, + filter: "provider_key IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "ix_user_credentials_user_id", + table: "user_credentials", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_users_email", + table: "users", + column: "email", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_vaccinations_family_member_id_date", + table: "vaccinations", + columns: new[] { "family_member_id", "date" }); + + migrationBuilder.CreateIndex( + name: "ix_wall_comment_mentions_family_member_id", + table: "wall_comment_mentions", + column: "family_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_wall_comments_author_member_id", + table: "wall_comments", + column: "author_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_wall_comments_message_id_created_at", + table: "wall_comments", + columns: new[] { "message_id", "created_at" }); + + migrationBuilder.CreateIndex( + name: "ix_wall_message_mentions_family_member_id", + table: "wall_message_mentions", + column: "family_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_wall_messages_author_member_id", + table: "wall_messages", + column: "author_member_id"); + + migrationBuilder.CreateIndex( + name: "ix_wall_messages_family_id_created_at", + table: "wall_messages", + columns: new[] { "family_id", "created_at" }); + + migrationBuilder.CreateIndex( + name: "ix_wall_messages_family_id_is_pinned_pinned_at", + table: "wall_messages", + columns: new[] { "family_id", "is_pinned", "pinned_at" }); + + migrationBuilder.CreateIndex( + name: "ix_wall_messages_image_file_id", + table: "wall_messages", + column: "image_file_id"); + + migrationBuilder.CreateIndex( + name: "ix_wall_reactions_member_id", + table: "wall_reactions", + column: "member_id"); + + migrationBuilder.CreateIndex( + name: "ix_wall_reactions_message_id_member_id_emoji", + table: "wall_reactions", + columns: new[] { "message_id", "member_id", "emoji" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "calendar_event_members"); + + migrationBuilder.DropTable( + name: "email_digest_runs"); + + migrationBuilder.DropTable( + name: "extracurricular_exceptions"); + + migrationBuilder.DropTable( + name: "health_profiles"); + + migrationBuilder.DropTable( + name: "household_task_related_members"); + + migrationBuilder.DropTable( + name: "integration_api_keys"); + + migrationBuilder.DropTable( + name: "invitations"); + + migrationBuilder.DropTable( + name: "meal_plan_slots"); + + migrationBuilder.DropTable( + name: "medications"); + + migrationBuilder.DropTable( + name: "member_agenda_exceptions"); + + migrationBuilder.DropTable( + name: "school_day_exceptions"); + + migrationBuilder.DropTable( + name: "school_day_schedules"); + + migrationBuilder.DropTable( + name: "school_holidays"); + + migrationBuilder.DropTable( + name: "school_profiles"); + + migrationBuilder.DropTable( + name: "task_completions"); + + migrationBuilder.DropTable( + name: "user_credentials"); + + migrationBuilder.DropTable( + name: "user_dashboard_preferences"); + + migrationBuilder.DropTable( + name: "user_notification_preferences"); + + migrationBuilder.DropTable( + name: "vaccinations"); + + migrationBuilder.DropTable( + name: "wall_comment_mentions"); + + migrationBuilder.DropTable( + name: "wall_message_mentions"); + + migrationBuilder.DropTable( + name: "wall_reactions"); + + migrationBuilder.DropTable( + name: "calendar_events"); + + migrationBuilder.DropTable( + name: "extracurriculars"); + + migrationBuilder.DropTable( + name: "member_agenda_patterns"); + + migrationBuilder.DropTable( + name: "household_tasks"); + + migrationBuilder.DropTable( + name: "wall_comments"); + + migrationBuilder.DropTable( + name: "linked_calendars"); + + migrationBuilder.DropTable( + name: "wall_messages"); + + migrationBuilder.DropTable( + name: "google_accounts"); + + migrationBuilder.DropTable( + name: "file_assets"); + + migrationBuilder.DropTable( + name: "family_members"); + + migrationBuilder.DropTable( + name: "families"); + + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/src/FamilyNido.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/FamilyNido.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..aa9650a --- /dev/null +++ b/src/FamilyNido.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,2747 @@ +// +using System; +using FamilyNido.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FamilyNido.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FamilyNido.Domain.Agenda.MemberAgendaException", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("EndTime") + .HasColumnType("time without time zone") + .HasColumnName("end_time"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("IsAway") + .HasColumnType("boolean") + .HasColumnName("is_away"); + + b.Property("IsCancelled") + .HasColumnType("boolean") + .HasColumnName("is_cancelled"); + + b.Property("Label") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("label"); + + b.Property("Location") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("location"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("notes"); + + b.Property("PatternId") + .HasColumnType("uuid") + .HasColumnName("pattern_id"); + + b.Property("StartTime") + .HasColumnType("time without time zone") + .HasColumnName("start_time"); + + b.Property("TransportMode") + .HasColumnType("integer") + .HasColumnName("transport_mode"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_member_agenda_exceptions"); + + b.HasIndex("PatternId") + .HasDatabaseName("ix_member_agenda_exceptions_pattern_id"); + + b.HasIndex("FamilyId", "Date") + .HasDatabaseName("ix_member_agenda_exceptions_family_id_date"); + + b.HasIndex("FamilyMemberId", "Date", "PatternId") + .IsUnique() + .HasDatabaseName("ix_member_agenda_exceptions_family_member_id_date_pattern_id"); + + b.ToTable("member_agenda_exceptions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Agenda.MemberAgendaPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("DayOfWeek") + .HasColumnType("integer") + .HasColumnName("day_of_week"); + + b.Property("EndTime") + .HasColumnType("time without time zone") + .HasColumnName("end_time"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsAway") + .HasColumnType("boolean") + .HasColumnName("is_away"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("label"); + + b.Property("Location") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("location"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("notes"); + + b.Property("StartTime") + .HasColumnType("time without time zone") + .HasColumnName("start_time"); + + b.Property("TransportMode") + .HasColumnType("integer") + .HasColumnName("transport_mode"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_member_agenda_patterns"); + + b.HasIndex("FamilyId") + .HasDatabaseName("ix_member_agenda_patterns_family_id"); + + b.HasIndex("FamilyMemberId", "DayOfWeek") + .HasDatabaseName("ix_member_agenda_patterns_family_member_id_day_of_week"); + + b.ToTable("member_agenda_patterns", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.CalendarEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_at"); + + b.Property("ExternalEventId") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("external_event_id"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("HtmlLink") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("html_link"); + + b.Property("IcalUid") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("ical_uid"); + + b.Property("IsAllDay") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_all_day"); + + b.Property("LinkedCalendarId") + .HasColumnType("uuid") + .HasColumnName("linked_calendar_id"); + + b.Property("Location") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("location"); + + b.Property("OriginalTimeZone") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("original_time_zone"); + + b.Property("StartAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_at"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_calendar_events"); + + b.HasIndex("FamilyId", "EndAt") + .HasDatabaseName("ix_calendar_events_family_id_end_at"); + + b.HasIndex("FamilyId", "StartAt") + .HasDatabaseName("ix_calendar_events_family_id_start_at"); + + b.HasIndex("LinkedCalendarId", "ExternalEventId") + .IsUnique() + .HasDatabaseName("ix_calendar_events_linked_calendar_id_external_event_id"); + + b.ToTable("calendar_events", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.GoogleAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("encrypted_refresh_token"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_revoked"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_error"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_google_accounts"); + + b.HasIndex("FamilyId") + .HasDatabaseName("ix_google_accounts_family_id"); + + b.HasIndex("UserId", "Email") + .IsUnique() + .HasDatabaseName("ix_google_accounts_user_id_email"); + + b.ToTable("google_accounts", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.LinkedCalendar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ColorHex") + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("color_hex"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("ExternalCalendarId") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("external_calendar_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("GoogleAccountId") + .HasColumnType("uuid") + .HasColumnName("google_account_id"); + + b.Property("IsImported") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_imported"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("summary"); + + b.Property("SyncToken") + .HasColumnType("text") + .HasColumnName("sync_token"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_linked_calendars"); + + b.HasIndex("FamilyMemberId") + .HasDatabaseName("ix_linked_calendars_family_member_id"); + + b.HasIndex("GoogleAccountId", "ExternalCalendarId") + .IsUnique() + .HasDatabaseName("ix_linked_calendars_google_account_id_external_calendar_id"); + + b.HasIndex("GoogleAccountId", "IsImported") + .HasDatabaseName("ix_linked_calendars_google_account_id_is_imported"); + + b.ToTable("linked_calendars", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Families.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasColumnName("latitude"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("LocationLabel") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("location_label"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasColumnName("longitude"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("time_zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_families"); + + b.ToTable("families", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Families.FamilyMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BirthDate") + .HasColumnType("date") + .HasColumnName("birth_date"); + + b.Property("ColorHex") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("color_hex"); + + b.Property("ContactEmail") + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("contact_email"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("display_name"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("MemberType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("member_type"); + + b.Property("PhotoPath") + .HasMaxLength(260) + .HasColumnType("character varying(260)") + .HasColumnName("photo_path"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_family_members"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_family_members_user_id") + .HasFilter("user_id IS NOT NULL"); + + b.HasIndex("FamilyId", "IsActive") + .HasDatabaseName("ix_family_members_family_id_is_active"); + + b.ToTable("family_members", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Files.FileAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("content_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("Height") + .HasColumnType("integer") + .HasColumnName("height"); + + b.Property("OwnerMemberId") + .HasColumnType("uuid") + .HasColumnName("owner_member_id"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("relative_path"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("Width") + .HasColumnType("integer") + .HasColumnName("width"); + + b.HasKey("Id") + .HasName("pk_file_assets"); + + b.HasIndex("FamilyId") + .HasDatabaseName("ix_file_assets_family_id"); + + b.HasIndex("OwnerMemberId") + .HasDatabaseName("ix_file_assets_owner_member_id"); + + b.HasIndex("RelativePath") + .IsUnique() + .HasDatabaseName("ix_file_assets_relative_path"); + + b.ToTable("file_assets", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.HealthProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Allergies") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("blood_type"); + + b.Property("ChronicConditions") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("chronic_conditions"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("notes"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_health_profiles"); + + b.HasIndex("FamilyMemberId") + .IsUnique() + .HasDatabaseName("ix_health_profiles_family_member_id"); + + b.ToTable("health_profiles", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.Medication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Dose") + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("dose"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("Frequency") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("frequency"); + + b.Property("Instructions") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("instructions"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_medications"); + + b.HasIndex("FamilyMemberId", "StartDate") + .HasDatabaseName("ix_medications_family_member_id_start_date"); + + b.ToTable("medications", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.Vaccination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.Property("NextDueDate") + .HasColumnType("date") + .HasColumnName("next_due_date"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("notes"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_vaccinations"); + + b.HasIndex("FamilyMemberId", "Date") + .HasDatabaseName("ix_vaccinations_family_member_id_date"); + + b.ToTable("vaccinations", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.HouseholdTasks.HouseholdTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasDefaultValue("General") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid") + .HasColumnName("created_by_member_id"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("DueDate") + .HasColumnType("date") + .HasColumnName("due_date"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("IsArchived") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_archived"); + + b.Property("IsFloating") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_floating"); + + b.Property("MonthlyDay") + .HasColumnType("integer") + .HasColumnName("monthly_day"); + + b.Property("Points") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(5) + .HasColumnName("points"); + + b.Property("Recurrence") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("recurrence"); + + b.Property("ResponsibleMemberId") + .HasColumnType("uuid") + .HasColumnName("responsible_member_id"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("TimeOfDay") + .HasColumnType("time without time zone") + .HasColumnName("time_of_day"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("WeeklyDays") + .HasColumnType("smallint") + .HasColumnName("weekly_days"); + + b.HasKey("Id") + .HasName("pk_household_tasks"); + + b.HasIndex("CreatedByMemberId") + .HasDatabaseName("ix_household_tasks_created_by_member_id"); + + b.HasIndex("FamilyId") + .HasDatabaseName("ix_household_tasks_family_id"); + + b.HasIndex("ResponsibleMemberId") + .HasDatabaseName("ix_household_tasks_responsible_member_id"); + + b.HasIndex("FamilyId", "IsArchived") + .HasDatabaseName("ix_household_tasks_family_id_is_archived"); + + b.ToTable("household_tasks", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.HouseholdTasks.TaskCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CompletedByMemberId") + .HasColumnType("uuid") + .HasColumnName("completed_by_member_id"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("OccurrenceDate") + .HasColumnType("date") + .HasColumnName("occurrence_date"); + + b.Property("TaskId") + .HasColumnType("uuid") + .HasColumnName("task_id"); + + b.HasKey("Id") + .HasName("pk_task_completions"); + + b.HasIndex("CompletedByMemberId") + .HasDatabaseName("ix_task_completions_completed_by_member_id"); + + b.HasIndex("TaskId", "OccurrenceDate") + .IsUnique() + .HasDatabaseName("ix_task_completions_task_id_occurrence_date"); + + b.ToTable("task_completions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("ConsumedByUserId") + .HasColumnType("uuid") + .HasColumnName("consumed_by_user_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RoleOnAccept") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("role_on_accept"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("token_hash"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_invitations"); + + b.HasIndex("FamilyMemberId") + .HasDatabaseName("ix_invitations_family_member_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_invitations_token_hash"); + + b.HasIndex("FamilyId", "ConsumedAt", "RevokedAt") + .HasDatabaseName("ix_invitations_family_id_consumed_at_revoked_at"); + + b.ToTable("invitations", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("LastWallReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_wall_read_at"); + + b.Property("PreferredLanguage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("es-ES") + .HasColumnName("preferred_language"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.UserCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("PasswordHash") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("password_hash"); + + b.Property("Provider") + .HasColumnType("integer") + .HasColumnName("provider"); + + b.Property("ProviderKey") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("provider_key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_credentials"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_credentials_user_id"); + + b.HasIndex("Provider", "ProviderKey") + .IsUnique() + .HasDatabaseName("ix_user_credentials_provider_provider_key") + .HasFilter("provider_key IS NOT NULL"); + + b.ToTable("user_credentials", null, t => + { + t.HasCheckConstraint("ck_user_credentials_shape", "(provider = 0 AND provider_key IS NOT NULL AND password_hash IS NULL) OR (provider = 1 AND provider_key IS NULL AND password_hash IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FamilyNido.Domain.Integrations.IntegrationApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorMemberId") + .HasColumnType("uuid") + .HasColumnName("author_member_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("name"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("prefix"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_integration_api_keys"); + + b.HasIndex("AuthorMemberId") + .HasDatabaseName("ix_integration_api_keys_author_member_id"); + + b.HasIndex("FamilyId") + .HasDatabaseName("ix_integration_api_keys_family_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_integration_api_keys_token_hash"); + + b.ToTable("integration_api_keys", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Meals.MealPlanSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FirstCourse") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("first_course"); + + b.Property("SecondCourse") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("second_course"); + + b.Property("Slot") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("slot"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_meal_plan_slots"); + + b.HasIndex("FamilyId", "FirstCourse") + .HasDatabaseName("ix_meal_plan_slots_family_id_first_course"); + + b.HasIndex("FamilyId", "SecondCourse") + .HasDatabaseName("ix_meal_plan_slots_family_id_second_course"); + + b.HasIndex("FamilyId", "Date", "Slot") + .IsUnique() + .HasDatabaseName("ix_meal_plan_slots_family_id_date_slot"); + + b.ToTable("meal_plan_slots", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.EmailDigestRun", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LocalDate") + .HasColumnType("date") + .HasColumnName("local_date"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.HasKey("UserId", "LocalDate") + .HasName("pk_email_digest_runs"); + + b.ToTable("email_digest_runs", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.UserDashboardPreferences", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WidgetsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("widgets_json"); + + b.HasKey("UserId") + .HasName("pk_user_dashboard_preferences"); + + b.ToTable("user_dashboard_preferences", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.UserNotificationPreferences", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("DigestEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("digest_enabled"); + + b.Property("EmailEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("email_enabled"); + + b.Property("TaskAssignedEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("task_assigned_enabled"); + + b.Property("WallMentionEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("wall_mention_enabled"); + + b.HasKey("UserId") + .HasName("pk_user_notification_preferences"); + + b.ToTable("user_notification_preferences", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.Extracurricular", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContactPhone") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("contact_phone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("DefaultDropoffMemberId") + .HasColumnType("uuid") + .HasColumnName("default_dropoff_member_id"); + + b.Property("DefaultPickupMemberId") + .HasColumnType("uuid") + .HasColumnName("default_pickup_member_id"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("EndTime") + .HasColumnType("time without time zone") + .HasColumnName("end_time"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("IsArchived") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_archived"); + + b.Property("Location") + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("location"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("notes"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("StartTime") + .HasColumnType("time without time zone") + .HasColumnName("start_time"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.Property("WeeklyDays") + .HasColumnType("smallint") + .HasColumnName("weekly_days"); + + b.HasKey("Id") + .HasName("pk_extracurriculars"); + + b.HasIndex("DefaultDropoffMemberId") + .HasDatabaseName("ix_extracurriculars_default_dropoff_member_id"); + + b.HasIndex("DefaultPickupMemberId") + .HasDatabaseName("ix_extracurriculars_default_pickup_member_id"); + + b.HasIndex("FamilyMemberId") + .HasDatabaseName("ix_extracurriculars_family_member_id"); + + b.HasIndex("FamilyId", "IsArchived") + .HasDatabaseName("ix_extracurriculars_family_id_is_archived"); + + b.ToTable("extracurriculars", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.ExtracurricularException", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("DropoffMemberId") + .HasColumnType("uuid") + .HasColumnName("dropoff_member_id"); + + b.Property("ExtracurricularId") + .HasColumnType("uuid") + .HasColumnName("extracurricular_id"); + + b.Property("IsCancelled") + .HasColumnType("boolean") + .HasColumnName("is_cancelled"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("notes"); + + b.Property("PickupMemberId") + .HasColumnType("uuid") + .HasColumnName("pickup_member_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_extracurricular_exceptions"); + + b.HasIndex("DropoffMemberId") + .HasDatabaseName("ix_extracurricular_exceptions_dropoff_member_id"); + + b.HasIndex("PickupMemberId") + .HasDatabaseName("ix_extracurricular_exceptions_pickup_member_id"); + + b.HasIndex("ExtracurricularId", "Date") + .IsUnique() + .HasDatabaseName("ix_extracurricular_exceptions_extracurricular_id_date"); + + b.ToTable("extracurricular_exceptions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolDayException", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AfternoonTime") + .HasColumnType("time without time zone") + .HasColumnName("afternoon_time"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("DropoffMemberId") + .HasColumnType("uuid") + .HasColumnName("dropoff_member_id"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("IsCancelled") + .HasColumnType("boolean") + .HasColumnName("is_cancelled"); + + b.Property("MorningTime") + .HasColumnType("time without time zone") + .HasColumnName("morning_time"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("notes"); + + b.Property("PickupMemberId") + .HasColumnType("uuid") + .HasColumnName("pickup_member_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_school_day_exceptions"); + + b.HasIndex("DropoffMemberId") + .HasDatabaseName("ix_school_day_exceptions_dropoff_member_id"); + + b.HasIndex("PickupMemberId") + .HasDatabaseName("ix_school_day_exceptions_pickup_member_id"); + + b.HasIndex("FamilyId", "Date") + .HasDatabaseName("ix_school_day_exceptions_family_id_date"); + + b.HasIndex("FamilyMemberId", "Date") + .IsUnique() + .HasDatabaseName("ix_school_day_exceptions_family_member_id_date"); + + b.ToTable("school_day_exceptions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolDaySchedule", b => + { + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("DayOfWeek") + .HasColumnType("integer") + .HasColumnName("day_of_week"); + + b.Property("DropoffMemberId") + .HasColumnType("uuid") + .HasColumnName("dropoff_member_id"); + + b.Property("PickupMemberId") + .HasColumnType("uuid") + .HasColumnName("pickup_member_id"); + + b.HasKey("FamilyMemberId", "DayOfWeek") + .HasName("pk_school_day_schedules"); + + b.HasIndex("DropoffMemberId") + .HasDatabaseName("ix_school_day_schedules_dropoff_member_id"); + + b.HasIndex("PickupMemberId") + .HasDatabaseName("ix_school_day_schedules_pickup_member_id"); + + b.ToTable("school_day_schedules", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("label"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_school_holidays"); + + b.HasIndex("FamilyId", "StartDate") + .HasDatabaseName("ix_school_holidays_family_id_start_date"); + + b.ToTable("school_holidays", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AfternoonTime") + .HasColumnType("time without time zone") + .HasColumnName("afternoon_time"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.Property("Grade") + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("grade"); + + b.Property("MorningTime") + .HasColumnType("time without time zone") + .HasColumnName("morning_time"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("notes"); + + b.Property("SchoolName") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("school_name"); + + b.Property("TransportMode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("transport_mode"); + + b.Property("Tutor") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("tutor"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_school_profiles"); + + b.HasIndex("FamilyMemberId") + .IsUnique() + .HasDatabaseName("ix_school_profiles_family_member_id"); + + b.ToTable("school_profiles", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorMemberId") + .HasColumnType("uuid") + .HasColumnName("author_member_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("text"); + + b.Property("TextHtml") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("text_html"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_wall_comments"); + + b.HasIndex("AuthorMemberId") + .HasDatabaseName("ix_wall_comments_author_member_id"); + + b.HasIndex("MessageId", "CreatedAt") + .HasDatabaseName("ix_wall_comments_message_id_created_at"); + + b.ToTable("wall_comments", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallCommentMention", b => + { + b.Property("CommentId") + .HasColumnType("uuid") + .HasColumnName("comment_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.HasKey("CommentId", "FamilyMemberId") + .HasName("pk_wall_comment_mentions"); + + b.HasIndex("FamilyMemberId") + .HasDatabaseName("ix_wall_comment_mentions_family_member_id"); + + b.ToTable("wall_comment_mentions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorMemberId") + .HasColumnType("uuid") + .HasColumnName("author_member_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("created_by"); + + b.Property("FamilyId") + .HasColumnType("uuid") + .HasColumnName("family_id"); + + b.Property("ImageFileId") + .HasColumnType("uuid") + .HasColumnName("image_file_id"); + + b.Property("IsPinned") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_pinned"); + + b.Property("PinnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("pinned_at"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("text"); + + b.Property("TextHtml") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("text_html"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_wall_messages"); + + b.HasIndex("AuthorMemberId") + .HasDatabaseName("ix_wall_messages_author_member_id"); + + b.HasIndex("ImageFileId") + .HasDatabaseName("ix_wall_messages_image_file_id"); + + b.HasIndex("FamilyId", "CreatedAt") + .HasDatabaseName("ix_wall_messages_family_id_created_at"); + + b.HasIndex("FamilyId", "IsPinned", "PinnedAt") + .HasDatabaseName("ix_wall_messages_family_id_is_pinned_pinned_at"); + + b.ToTable("wall_messages", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallMessageMention", b => + { + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("FamilyMemberId") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.HasKey("MessageId", "FamilyMemberId") + .HasName("pk_wall_message_mentions"); + + b.HasIndex("FamilyMemberId") + .HasDatabaseName("ix_wall_message_mentions_family_member_id"); + + b.ToTable("wall_message_mentions", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Emoji") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("MemberId") + .HasColumnType("uuid") + .HasColumnName("member_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("ReactedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reacted_at"); + + b.HasKey("Id") + .HasName("pk_wall_reactions"); + + b.HasIndex("MemberId") + .HasDatabaseName("ix_wall_reactions_member_id"); + + b.HasIndex("MessageId", "MemberId", "Emoji") + .IsUnique() + .HasDatabaseName("ix_wall_reactions_message_id_member_id_emoji"); + + b.ToTable("wall_reactions", (string)null); + }); + + modelBuilder.Entity("calendar_event_members", b => + { + b.Property("calendar_event_id") + .HasColumnType("uuid") + .HasColumnName("calendar_event_id"); + + b.Property("family_member_id") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.HasKey("calendar_event_id", "family_member_id") + .HasName("pk_calendar_event_members"); + + b.HasIndex("family_member_id") + .HasDatabaseName("ix_calendar_event_members_family_member_id"); + + b.ToTable("calendar_event_members", (string)null); + }); + + modelBuilder.Entity("household_task_related_members", b => + { + b.Property("task_id") + .HasColumnType("uuid") + .HasColumnName("task_id"); + + b.Property("family_member_id") + .HasColumnType("uuid") + .HasColumnName("family_member_id"); + + b.HasKey("task_id", "family_member_id") + .HasName("pk_household_task_related_members"); + + b.HasIndex("family_member_id") + .HasDatabaseName("ix_household_task_related_members_family_member_id"); + + b.ToTable("household_task_related_members", (string)null); + }); + + modelBuilder.Entity("FamilyNido.Domain.Agenda.MemberAgendaException", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_member_agenda_exceptions_family_members_family_member_id"); + + b.HasOne("FamilyNido.Domain.Agenda.MemberAgendaPattern", "Pattern") + .WithMany() + .HasForeignKey("PatternId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_member_agenda_exceptions_member_agenda_patterns_pattern_id"); + + b.Navigation("FamilyMember"); + + b.Navigation("Pattern"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Agenda.MemberAgendaPattern", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_member_agenda_patterns_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.CalendarEvent", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_calendar_events_families_family_id"); + + b.HasOne("FamilyNido.Domain.Calendar.LinkedCalendar", "LinkedCalendar") + .WithMany("Events") + .HasForeignKey("LinkedCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_calendar_events_linked_calendars_linked_calendar_id"); + + b.Navigation("Family"); + + b.Navigation("LinkedCalendar"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.GoogleAccount", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_google_accounts_families_family_id"); + + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_google_accounts_users_user_id"); + + b.Navigation("Family"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.LinkedCalendar", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_linked_calendars_family_members_family_member_id"); + + b.HasOne("FamilyNido.Domain.Calendar.GoogleAccount", "GoogleAccount") + .WithMany("Calendars") + .HasForeignKey("GoogleAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_linked_calendars_google_accounts_google_account_id"); + + b.Navigation("FamilyMember"); + + b.Navigation("GoogleAccount"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Families.FamilyMember", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany("Members") + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_family_members_families_family_id"); + + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithOne("FamilyMember") + .HasForeignKey("FamilyNido.Domain.Families.FamilyMember", "UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_family_members_users_user_id"); + + b.Navigation("Family"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Files.FileAsset", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_file_assets_families_family_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "OwnerMember") + .WithMany() + .HasForeignKey("OwnerMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_file_assets_family_members_owner_member_id"); + + b.Navigation("Family"); + + b.Navigation("OwnerMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.HealthProfile", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithOne() + .HasForeignKey("FamilyNido.Domain.Health.HealthProfile", "FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_health_profiles_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.Medication", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_medications_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Health.Vaccination", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vaccinations_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.HouseholdTasks.HouseholdTask", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "CreatedByMember") + .WithMany() + .HasForeignKey("CreatedByMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_household_tasks_family_members_created_by_member_id"); + + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_household_tasks_families_family_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "ResponsibleMember") + .WithMany() + .HasForeignKey("ResponsibleMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_household_tasks_family_members_responsible_member_id"); + + b.Navigation("CreatedByMember"); + + b.Navigation("Family"); + + b.Navigation("ResponsibleMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.HouseholdTasks.TaskCompletion", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "CompletedBy") + .WithMany() + .HasForeignKey("CompletedByMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_task_completions_family_members_completed_by_member_id"); + + b.HasOne("FamilyNido.Domain.HouseholdTasks.HouseholdTask", "Task") + .WithMany("Completions") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_task_completions_household_tasks_task_id"); + + b.Navigation("CompletedBy"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.Invitation", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_invitations_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.UserCredential", b => + { + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithMany("Credentials") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_credentials_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Integrations.IntegrationApiKey", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "AuthorMember") + .WithMany() + .HasForeignKey("AuthorMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_integration_api_keys_family_members_author_member_id"); + + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_integration_api_keys_families_family_id"); + + b.Navigation("AuthorMember"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Meals.MealPlanSlot", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_meal_plan_slots_families_family_id"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.EmailDigestRun", b => + { + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_email_digest_runs_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.UserDashboardPreferences", b => + { + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithOne() + .HasForeignKey("FamilyNido.Domain.Notifications.UserDashboardPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_dashboard_preferences_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Notifications.UserNotificationPreferences", b => + { + b.HasOne("FamilyNido.Domain.Identity.User", "User") + .WithOne() + .HasForeignKey("FamilyNido.Domain.Notifications.UserNotificationPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_notification_preferences_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.Extracurricular", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "DefaultDropoffMember") + .WithMany() + .HasForeignKey("DefaultDropoffMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_extracurriculars_family_members_default_dropoff_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "DefaultPickupMember") + .WithMany() + .HasForeignKey("DefaultPickupMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_extracurriculars_family_members_default_pickup_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_extracurriculars_family_members_family_member_id"); + + b.Navigation("DefaultDropoffMember"); + + b.Navigation("DefaultPickupMember"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.ExtracurricularException", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "DropoffMember") + .WithMany() + .HasForeignKey("DropoffMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_extracurricular_exceptions_family_members_dropoff_member_id"); + + b.HasOne("FamilyNido.Domain.School.Extracurricular", "Extracurricular") + .WithMany("Exceptions") + .HasForeignKey("ExtracurricularId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_extracurricular_exceptions_extracurriculars_extracurricular"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "PickupMember") + .WithMany() + .HasForeignKey("PickupMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_extracurricular_exceptions_family_members_pickup_member_id"); + + b.Navigation("DropoffMember"); + + b.Navigation("Extracurricular"); + + b.Navigation("PickupMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolDayException", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "DropoffMember") + .WithMany() + .HasForeignKey("DropoffMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_school_day_exceptions_family_members_dropoff_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_school_day_exceptions_family_members_family_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "PickupMember") + .WithMany() + .HasForeignKey("PickupMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_school_day_exceptions_family_members_pickup_member_id"); + + b.Navigation("DropoffMember"); + + b.Navigation("FamilyMember"); + + b.Navigation("PickupMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolDaySchedule", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "DropoffMember") + .WithMany() + .HasForeignKey("DropoffMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_school_day_schedules_family_members_dropoff_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_school_day_schedules_family_members_family_member_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "PickupMember") + .WithMany() + .HasForeignKey("PickupMemberId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_school_day_schedules_family_members_pickup_member_id"); + + b.Navigation("DropoffMember"); + + b.Navigation("FamilyMember"); + + b.Navigation("PickupMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolHoliday", b => + { + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_school_holidays_families_family_id"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.SchoolProfile", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithOne() + .HasForeignKey("FamilyNido.Domain.School.SchoolProfile", "FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_school_profiles_family_members_family_member_id"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallComment", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "AuthorMember") + .WithMany() + .HasForeignKey("AuthorMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_wall_comments_family_members_author_member_id"); + + b.HasOne("FamilyNido.Domain.Wall.WallMessage", "Message") + .WithMany("Comments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_comments_wall_messages_message_id"); + + b.Navigation("AuthorMember"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallCommentMention", b => + { + b.HasOne("FamilyNido.Domain.Wall.WallComment", "Comment") + .WithMany("Mentions") + .HasForeignKey("CommentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_comment_mentions_wall_comments_comment_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_comment_mentions_family_members_family_member_id"); + + b.Navigation("Comment"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallMessage", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "AuthorMember") + .WithMany() + .HasForeignKey("AuthorMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_wall_messages_family_members_author_member_id"); + + b.HasOne("FamilyNido.Domain.Families.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_wall_messages_families_family_id"); + + b.HasOne("FamilyNido.Domain.Files.FileAsset", "ImageFile") + .WithMany() + .HasForeignKey("ImageFileId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_wall_messages_file_assets_image_file_id"); + + b.Navigation("AuthorMember"); + + b.Navigation("Family"); + + b.Navigation("ImageFile"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallMessageMention", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "FamilyMember") + .WithMany() + .HasForeignKey("FamilyMemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_message_mentions_family_members_family_member_id"); + + b.HasOne("FamilyNido.Domain.Wall.WallMessage", "Message") + .WithMany("Mentions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_message_mentions_wall_messages_message_id"); + + b.Navigation("FamilyMember"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallReaction", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_reactions_family_members_member_id"); + + b.HasOne("FamilyNido.Domain.Wall.WallMessage", "Message") + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wall_reactions_wall_messages_message_id"); + + b.Navigation("Member"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("calendar_event_members", b => + { + b.HasOne("FamilyNido.Domain.Calendar.CalendarEvent", null) + .WithMany() + .HasForeignKey("calendar_event_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_calendar_event_members_calendar_events_calendar_event_id"); + + b.HasOne("FamilyNido.Domain.Families.FamilyMember", null) + .WithMany() + .HasForeignKey("family_member_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_calendar_event_members_family_members_family_member_id"); + }); + + modelBuilder.Entity("household_task_related_members", b => + { + b.HasOne("FamilyNido.Domain.Families.FamilyMember", null) + .WithMany() + .HasForeignKey("family_member_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_household_task_related_members_family_members_family_member"); + + b.HasOne("FamilyNido.Domain.HouseholdTasks.HouseholdTask", null) + .WithMany() + .HasForeignKey("task_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_household_task_related_members_household_tasks_task_id"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.GoogleAccount", b => + { + b.Navigation("Calendars"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Calendar.LinkedCalendar", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Families.Family", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("FamilyNido.Domain.HouseholdTasks.HouseholdTask", b => + { + b.Navigation("Completions"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Identity.User", b => + { + b.Navigation("Credentials"); + + b.Navigation("FamilyMember"); + }); + + modelBuilder.Entity("FamilyNido.Domain.School.Extracurricular", b => + { + b.Navigation("Exceptions"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallComment", b => + { + b.Navigation("Mentions"); + }); + + modelBuilder.Entity("FamilyNido.Domain.Wall.WallMessage", b => + { + b.Navigation("Comments"); + + b.Navigation("Mentions"); + + b.Navigation("Reactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/tests/FamilyNido.Tests/FamilyNido.Tests.csproj b/tests/FamilyNido.Tests/FamilyNido.Tests.csproj new file mode 100644 index 0000000..f4a207c --- /dev/null +++ b/tests/FamilyNido.Tests/FamilyNido.Tests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/FamilyNido.Tests/HouseholdTasks/EnumerateOccurrencesTests.cs b/tests/FamilyNido.Tests/HouseholdTasks/EnumerateOccurrencesTests.cs new file mode 100644 index 0000000..14b5e00 --- /dev/null +++ b/tests/FamilyNido.Tests/HouseholdTasks/EnumerateOccurrencesTests.cs @@ -0,0 +1,268 @@ +using FamilyNido.Domain.HouseholdTasks; +using FluentAssertions; + +namespace FamilyNido.Tests.HouseholdTasks; + +/// +/// Pure-domain tests for โ€” the engine that +/// drives "tasks for today / this week" queries. Kept in-process (no DB) so edge cases are +/// exhaustively covered without migration overhead. +/// +public sealed class EnumerateOccurrencesTests +{ + private static HouseholdTask MakeTask( + RecurrenceMode mode, + DateOnly startDate, + DateOnly? dueDate = null, + DayOfWeekMask? weeklyDays = null, + int? monthlyDay = null, + bool archived = false) => new() + { + FamilyId = Guid.CreateVersion7(), + CreatedByMemberId = Guid.CreateVersion7(), + Title = "Test", + Recurrence = mode, + StartDate = startDate, + DueDate = dueDate, + WeeklyDays = weeklyDays, + MonthlyDay = monthlyDay, + IsArchived = archived, + }; + + [Fact] + public void Archived_task_yields_no_occurrences() + { + var task = MakeTask(RecurrenceMode.Daily, new DateOnly(2026, 1, 1), archived: true); + + task.EnumerateOccurrences(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 10)) + .Should().BeEmpty(); + } + + [Fact] + public void Inverted_range_yields_no_occurrences() + { + var task = MakeTask(RecurrenceMode.Daily, new DateOnly(2026, 1, 1)); + + task.EnumerateOccurrences(new DateOnly(2026, 1, 10), new DateOnly(2026, 1, 1)) + .Should().BeEmpty(); + } + + [Fact] + public void Daily_yields_one_occurrence_per_day_in_range() + { + var task = MakeTask(RecurrenceMode.Daily, new DateOnly(2026, 1, 1)); + + var occurrences = task.EnumerateOccurrences( + new DateOnly(2026, 1, 5), + new DateOnly(2026, 1, 8)).ToList(); + + occurrences.Should().Equal( + new DateOnly(2026, 1, 5), + new DateOnly(2026, 1, 6), + new DateOnly(2026, 1, 7), + new DateOnly(2026, 1, 8)); + } + + [Fact] + public void Daily_respects_start_date_as_floor() + { + var task = MakeTask(RecurrenceMode.Daily, new DateOnly(2026, 1, 10)); + + var occurrences = task.EnumerateOccurrences( + new DateOnly(2026, 1, 5), + new DateOnly(2026, 1, 12)).ToList(); + + occurrences.Should().Equal( + new DateOnly(2026, 1, 10), + new DateOnly(2026, 1, 11), + new DateOnly(2026, 1, 12)); + } + + [Fact] + public void Weekly_only_yields_selected_weekdays() + { + // 2026-04-20 is Monday; 2026-05-03 is Sunday. + var task = MakeTask( + RecurrenceMode.Weekly, + new DateOnly(2026, 4, 20), + weeklyDays: DayOfWeekMask.Monday | DayOfWeekMask.Thursday); + + var occurrences = task.EnumerateOccurrences( + new DateOnly(2026, 4, 20), + new DateOnly(2026, 5, 3)).ToList(); + + occurrences.Should().Equal( + new DateOnly(2026, 4, 20), // Mon + new DateOnly(2026, 4, 23), // Thu + new DateOnly(2026, 4, 27), // Mon + new DateOnly(2026, 4, 30)); // Thu โ€” May 3 is Sunday, not selected. + } + + [Fact] + public void Weekly_with_no_selected_days_yields_nothing() + { + var task = MakeTask( + RecurrenceMode.Weekly, + new DateOnly(2026, 4, 20), + weeklyDays: DayOfWeekMask.None); + + task.EnumerateOccurrences(new DateOnly(2026, 4, 20), new DateOnly(2026, 5, 20)) + .Should().BeEmpty(); + } + + [Fact] + public void Weekly_null_selection_yields_nothing() + { + var task = MakeTask(RecurrenceMode.Weekly, new DateOnly(2026, 4, 20)); + + task.EnumerateOccurrences(new DateOnly(2026, 4, 20), new DateOnly(2026, 5, 20)) + .Should().BeEmpty(); + } + + [Fact] + public void Monthly_yields_one_occurrence_per_month_on_matching_day() + { + var task = MakeTask( + RecurrenceMode.Monthly, + new DateOnly(2026, 1, 1), + monthlyDay: 15); + + var occurrences = task.EnumerateOccurrences( + new DateOnly(2026, 1, 1), + new DateOnly(2026, 4, 30)).ToList(); + + occurrences.Should().Equal( + new DateOnly(2026, 1, 15), + new DateOnly(2026, 2, 15), + new DateOnly(2026, 3, 15), + new DateOnly(2026, 4, 15)); + } + + [Fact] + public void Monthly_day_31_collapses_to_last_day_of_short_months() + { + var task = MakeTask( + RecurrenceMode.Monthly, + new DateOnly(2026, 1, 1), + monthlyDay: 31); + + var occurrences = task.EnumerateOccurrences( + new DateOnly(2026, 1, 1), + new DateOnly(2026, 4, 30)).ToList(); + + occurrences.Should().Equal( + new DateOnly(2026, 1, 31), + new DateOnly(2026, 2, 28), // 2026 is not a leap year + new DateOnly(2026, 3, 31), + new DateOnly(2026, 4, 30)); + } + + [Fact] + public void Monthly_minus_one_always_yields_last_day_of_month() + { + var task = MakeTask( + RecurrenceMode.Monthly, + new DateOnly(2024, 1, 1), + monthlyDay: -1); + + var occurrences = task.EnumerateOccurrences( + new DateOnly(2024, 1, 1), + new DateOnly(2024, 4, 30)).ToList(); + + occurrences.Should().Equal( + new DateOnly(2024, 1, 31), + new DateOnly(2024, 2, 29), // 2024 is a leap year + new DateOnly(2024, 3, 31), + new DateOnly(2024, 4, 30)); + } + + [Fact] + public void Monthly_null_day_yields_nothing() + { + var task = MakeTask(RecurrenceMode.Monthly, new DateOnly(2026, 1, 1)); + + task.EnumerateOccurrences(new DateOnly(2026, 1, 1), new DateOnly(2026, 12, 31)) + .Should().BeEmpty(); + } + + [Fact] + public void None_with_same_start_and_due_date_yields_single_occurrence() + { + // Single-shot task: StartDate == DueDate. Behaves as a one-day occurrence. + var task = MakeTask( + RecurrenceMode.None, + new DateOnly(2026, 4, 25), + dueDate: new DateOnly(2026, 4, 25)); + + task.EnumerateOccurrences(new DateOnly(2026, 4, 20), new DateOnly(2026, 4, 30)) + .Should().Equal(new DateOnly(2026, 4, 25)); + } + + [Fact] + public void None_deadline_range_yields_every_day_in_window_intersection() + { + // Deadline task: StartDate < DueDate. Surfaces every day in + // [StartDate, DueDate] clipped to the requested window so the family + // sees it every morning until they finally complete it. + var task = MakeTask( + RecurrenceMode.None, + new DateOnly(2026, 1, 1), + dueDate: new DateOnly(2026, 4, 25)); + + task.EnumerateOccurrences(new DateOnly(2026, 4, 20), new DateOnly(2026, 4, 30)) + .Should().Equal( + new DateOnly(2026, 4, 20), + new DateOnly(2026, 4, 21), + new DateOnly(2026, 4, 22), + new DateOnly(2026, 4, 23), + new DateOnly(2026, 4, 24), + new DateOnly(2026, 4, 25)); + } + + [Fact] + public void None_deadline_range_clips_to_window_when_due_falls_after() + { + // DueDate after the window โ€” yield every day inside the window. + var task = MakeTask( + RecurrenceMode.None, + new DateOnly(2026, 1, 1), + dueDate: new DateOnly(2026, 5, 10)); + + task.EnumerateOccurrences(new DateOnly(2026, 4, 20), new DateOnly(2026, 4, 22)) + .Should().Equal( + new DateOnly(2026, 4, 20), + new DateOnly(2026, 4, 21), + new DateOnly(2026, 4, 22)); + } + + [Fact] + public void None_without_due_date_falls_back_to_start_date() + { + var task = MakeTask(RecurrenceMode.None, new DateOnly(2026, 4, 25)); + + task.EnumerateOccurrences(new DateOnly(2026, 4, 20), new DateOnly(2026, 4, 30)) + .Should().Equal(new DateOnly(2026, 4, 25)); + } + + [Fact] + public void Has_occurrence_on_matches_weekly_day() + { + // 2026-04-25 is Saturday. + var task = MakeTask( + RecurrenceMode.Weekly, + new DateOnly(2026, 4, 1), + weeklyDays: DayOfWeekMask.Saturday); + + task.HasOccurrenceOn(new DateOnly(2026, 4, 25)).Should().BeTrue(); + task.HasOccurrenceOn(new DateOnly(2026, 4, 24)).Should().BeFalse(); + } + + [Fact] + public void Has_occurrence_on_respects_start_date() + { + var task = MakeTask(RecurrenceMode.Daily, new DateOnly(2026, 4, 25)); + + task.HasOccurrenceOn(new DateOnly(2026, 4, 24)).Should().BeFalse(); + task.HasOccurrenceOn(new DateOnly(2026, 4, 25)).Should().BeTrue(); + } +} diff --git a/tests/FamilyNido.Tests/Integration/AuthTests.cs b/tests/FamilyNido.Tests/Integration/AuthTests.cs new file mode 100644 index 0000000..20667c1 --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/AuthTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; + +namespace FamilyNido.Tests.Integration; + +/// +/// Coverage for the local-credentials login slice. The OIDC flow is left +/// out โ€” it requires a real provider. Local login is what the tests rely on +/// throughout the suite, so it earns proper exhaustive coverage here. +/// +public sealed class AuthTests : IntegrationTestBase +{ + public AuthTests(IntegrationFixture fixture) : base(fixture) { } + + [Fact] + public async Task LocalLogin_returns_200_with_known_user_and_correct_password() + { + await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + + var response = await Client.PostAsJsonAsync("/api/auth/local/login", + new { email = "dan@example.com", password = TestSeed.DefaultPassword }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LocalLogin_returns_403_with_unknown_user() + { + // No seed at all โ€” the user simply does not exist. + var response = await Client.PostAsJsonAsync("/api/auth/local/login", + new { email = "nobody@example.com", password = "whatever" }); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task LocalLogin_returns_403_with_correct_user_but_wrong_password() + { + await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + + var response = await Client.PostAsJsonAsync("/api/auth/local/login", + new { email = "dan@example.com", password = "definitely-wrong" }); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task LocalLogin_returns_400_with_malformed_email() + { + var response = await Client.PostAsJsonAsync("/api/auth/local/login", + new { email = "not-an-email", password = "whatever" }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Providers_reports_oidc_disabled_when_authority_is_empty() + { + // Testing config leaves Oidc.Authority empty on purpose โ€” the test + // suite relies exclusively on local credentials and the login UI + // hides the OIDC button accordingly. + var response = await Client.GetAsync("/api/auth/providers"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadFromJsonAsync(); + body!.OidcEnabled.Should().BeFalse(); + } + + [Fact] + public async Task Login_endpoint_returns_404_when_oidc_is_disabled() + { + // With OIDC turned off the cookie-only stack should not surface a 500 + // when something pokes the legacy challenge endpoint; the API answers + // a clean 404 instead. + var response = await Client.GetAsync("/api/auth/login"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Theory] + [InlineData("https://evil.example.com/")] + [InlineData("//evil.example.com/")] + [InlineData("javascript:alert(1)")] + [InlineData("ftp://other-host/")] + public async Task Login_endpoint_rejects_external_returnUrls_to_prevent_open_redirect(string hostile) + { + // The login endpoint reflects returnUrl into the OIDC properties; an + // unvalidated value lets an attacker craft a post-login bounce to a + // phishing page. We can only assert here against the no-OIDC branch + // (Testing has Authority="") but the same sanitiser runs in the OIDC + // path right before Results.Challenge, so any redirect away from "/" + // would be discarded there too. + var response = await Client.GetAsync($"/api/auth/login?returnUrl={Uri.EscapeDataString(hostile)}"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Logout_clears_the_session_cookie() + { + await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + // Confirm we are in. + var meBefore = await Client.GetAsync("/api/auth/me"); + meBefore.StatusCode.Should().Be(HttpStatusCode.OK); + + var logout = await Client.PostAsync("/api/auth/logout", content: null); + logout.IsSuccessStatusCode.Should().BeTrue(); + + // After logout, /api/auth/me should be 401. + var meAfter = await Client.GetAsync("/api/auth/me"); + meAfter.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + private sealed record ProvidersDto(bool OidcEnabled); +} diff --git a/tests/FamilyNido.Tests/Integration/DigestTests.cs b/tests/FamilyNido.Tests/Integration/DigestTests.cs new file mode 100644 index 0000000..e8500dd --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/DigestTests.cs @@ -0,0 +1,94 @@ +using System.Net.Http.Json; +using FamilyNido.Domain.HouseholdTasks; +using FluentAssertions; + +namespace FamilyNido.Tests.Integration; + +/// +/// Coverage for the manual digest preview endpoint +/// (POST /api/notifications/digest/me) โ€” focused on the rule that +/// completed-today tasks must NOT appear in the email, while pending-today +/// tasks must. +/// +public sealed class DigestTests : IntegrationTestBase +{ + public DigestTests(IntegrationFixture fixture) : base(fixture) { } + + [Fact] + public async Task Digest_excludes_tasks_that_are_already_completed_for_today() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + await WithDbAsync(async db => + { + var task = new HouseholdTask + { + FamilyId = handle.Family.Id, + Title = "Recoger el lavavajillas", + Category = "Hogar", + Recurrence = RecurrenceMode.None, + StartDate = today, + DueDate = today, + IsFloating = false, + Points = 3, + CreatedByMemberId = handle.Member.Id, + }; + db.HouseholdTasks.Add(task); + await db.SaveChangesAsync(); + + db.TaskCompletions.Add(new TaskCompletion + { + TaskId = task.Id, + CompletedByMemberId = handle.Member.Id, + OccurrenceDate = today, + CompletedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + }); + + // The only thing seeded for today is a task that is already done. + // The digest must therefore have nothing to report. + var resp = await Client.PostAsync("/api/notifications/digest/me", content: null); + resp.IsSuccessStatusCode.Should().BeTrue(); + + var body = await ReadAsync(resp); + body!.IsEmpty.Should().BeTrue("a task already completed today should not appear in the digest"); + } + + [Fact] + public async Task Digest_includes_tasks_that_are_pending_for_today() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + await WithDbAsync(async db => + { + db.HouseholdTasks.Add(new HouseholdTask + { + FamilyId = handle.Family.Id, + Title = "Recoger la lavadora", + Category = "Hogar", + Recurrence = RecurrenceMode.None, + StartDate = today, + DueDate = today, + IsFloating = false, + Points = 3, + CreatedByMemberId = handle.Member.Id, + }); + await db.SaveChangesAsync(); + }); + + var resp = await Client.PostAsync("/api/notifications/digest/me", content: null); + resp.IsSuccessStatusCode.Should().BeTrue(); + + var body = await ReadAsync(resp); + body!.IsEmpty.Should().BeFalse("a pending task for today should be reported"); + } + + private sealed record DigestResponse(string Email, bool IsEmpty); +} diff --git a/tests/FamilyNido.Tests/Integration/FamilyMembersTests.cs b/tests/FamilyNido.Tests/Integration/FamilyMembersTests.cs new file mode 100644 index 0000000..c8cdaa9 --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/FamilyMembersTests.cs @@ -0,0 +1,174 @@ +using System.Net; +using System.Net.Http.Json; +using FamilyNido.Domain.Families; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Tests.Integration; + +/// +/// CRUD + permission coverage for /api/family-members. Exercises every +/// endpoint mapped in FamilyMemberEndpoints plus the admin gate. +/// +public sealed class FamilyMembersTests : IntegrationTestBase +{ + public FamilyMembersTests(IntegrationFixture fixture) : base(fixture) { } + + private async Task SeedAdminAsync() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + return handle; + } + + [Fact] + public async Task List_returns_admin_member_after_seed() + { + await SeedAdminAsync(); + + var response = await Client.GetAsync("/api/family-members"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var members = await ReadAsync>(response); + members.Should().NotBeNull(); + members.Should().HaveCount(1); + members![0].DisplayName.Should().Be("Dan"); + members[0].HasAccount.Should().BeTrue(); + members[0].Role.Should().Be(FamilyRole.Admin); + } + + [Fact] + public async Task Create_persists_new_member() + { + await SeedAdminAsync(); + + var body = new + { + displayName = "Bob", + memberType = "Child", + colorHex = "#7BA4A8", + birthDate = "2018-05-15", + contactEmail = (string?)null, + }; + var response = await Client.PostAsJsonAsync("/api/family-members", body); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + // Persisted row matches the input. + var saved = await WithDbAsync(db => db.FamilyMembers + .Where(m => m.DisplayName == "Bob") + .FirstAsync()); + saved.MemberType.Should().Be(MemberType.Child); + saved.ColorHex.Should().Be("#7BA4A8"); + saved.BirthDate.Should().Be(new DateOnly(2018, 5, 15)); + saved.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Create_rejects_invalid_color() + { + await SeedAdminAsync(); + + var body = new + { + displayName = "Bob", + memberType = "Child", + colorHex = "not-a-color", + }; + var response = await Client.PostAsJsonAsync("/api/family-members", body); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Update_changes_display_name_and_color() + { + var handle = await SeedAdminAsync(); + var added = await WithDbAsync(db => + TestSeed.SeedMemberAsync(db, handle.Family.Id, "Bob")); + + var body = new + { + displayName = "Telmito", + colorHex = "#FF0000", + birthDate = (string?)null, + contactEmail = (string?)null, + }; + var response = await Client.PutAsJsonAsync($"/api/family-members/{added.Id}", body); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var reloaded = await WithDbAsync(db => + db.FamilyMembers.Where(m => m.Id == added.Id).FirstAsync()); + reloaded.DisplayName.Should().Be("Telmito"); + reloaded.ColorHex.Should().Be("#FF0000"); + } + + [Fact] + public async Task Deactivate_then_activate_round_trips() + { + var handle = await SeedAdminAsync(); + var added = await WithDbAsync(db => + TestSeed.SeedMemberAsync(db, handle.Family.Id, "Charlie")); + + var off = await Client.PatchAsync($"/api/family-members/{added.Id}/deactivate", content: null); + off.StatusCode.Should().Be(HttpStatusCode.OK); + + var afterOff = await WithDbAsync(db => + db.FamilyMembers.Where(m => m.Id == added.Id).FirstAsync()); + afterOff.IsActive.Should().BeFalse(); + + var on = await Client.PatchAsync($"/api/family-members/{added.Id}/activate", content: null); + on.StatusCode.Should().Be(HttpStatusCode.OK); + + var afterOn = await WithDbAsync(db => + db.FamilyMembers.Where(m => m.Id == added.Id).FirstAsync()); + afterOn.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Delete_removes_the_row() + { + var handle = await SeedAdminAsync(); + var added = await WithDbAsync(db => + TestSeed.SeedMemberAsync(db, handle.Family.Id, "Aitite")); + + var response = await Client.DeleteAsync($"/api/family-members/{added.Id}"); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var stillThere = await WithDbAsync(db => + db.FamilyMembers.AnyAsync(m => m.Id == added.Id)); + stillThere.Should().BeFalse(); + } + + [Fact] + public async Task Non_admin_cannot_create_member() + { + // Seed an Adult (not Admin). + await WithDbAsync(db => TestSeed.SeedFamilyAsync( + db, Fixture.Factory.Services, + email: "alice@example.com", + displayName: "Alice", + role: FamilyRole.Adult)); + await TestSeed.LoginAsync(Client, "alice@example.com"); + + var body = new + { + displayName = "Bob", + memberType = "Child", + colorHex = "#7BA4A8", + }; + var response = await Client.PostAsJsonAsync("/api/family-members", body); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + /// Compact projection used to deserialize the list response. + private sealed record MemberRow( + Guid Id, + string DisplayName, + MemberType MemberType, + string ColorHex, + DateOnly? BirthDate, + string? ContactEmail, + string? PhotoPath, + bool IsActive, + bool HasAccount, + FamilyRole? Role); +} diff --git a/tests/FamilyNido.Tests/Integration/HouseholdTaskTests.cs b/tests/FamilyNido.Tests/Integration/HouseholdTaskTests.cs new file mode 100644 index 0000000..00b8be8 --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/HouseholdTaskTests.cs @@ -0,0 +1,333 @@ +using System.Net; +using System.Net.Http.Json; +using FamilyNido.Domain.HouseholdTasks; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Tests.Integration; + +/// +/// CRUD + completion-state coverage for /api/household-tasks. Hits create, +/// list, today/week views, complete/undo, archive/restore, deadline ranges +/// and floating tasks. +/// +public sealed class HouseholdTaskTests : IntegrationTestBase +{ + public HouseholdTaskTests(IntegrationFixture fixture) : base(fixture) { } + + private async Task SeedAdminAsync() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + return handle; + } + + private static object NewTaskBody( + string title, + string recurrence = "None", + DateOnly? startDate = null, + DateOnly? dueDate = null, + bool isFloating = false, + int points = 5) + { + return new + { + title, + description = (string?)null, + category = (string?)"General", + recurrence, + weeklyDays = (string?)null, + monthlyDay = (int?)null, + timeOfDay = (string?)null, + startDate = (startDate ?? DateOnly.FromDateTime(DateTime.UtcNow)).ToString("yyyy-MM-dd"), + dueDate = dueDate?.ToString("yyyy-MM-dd"), + responsibleMemberId = (Guid?)null, + relatedMemberIds = Array.Empty(), + isFloating, + points, + }; + } + + [Fact] + public async Task Create_persists_a_one_off_task() + { + await SeedAdminAsync(); + + var response = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Comprar pan")); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var saved = await WithDbAsync(db => + db.HouseholdTasks.Where(t => t.Title == "Comprar pan").FirstAsync()); + saved.Recurrence.Should().Be(RecurrenceMode.None); + saved.Points.Should().Be(5); + saved.IsArchived.Should().BeFalse(); + } + + [Fact] + public async Task Create_rejects_points_out_of_range() + { + await SeedAdminAsync(); + + var response = await Client.PostAsJsonAsync( + "/api/household-tasks/", + NewTaskBody("Pintar", points: 99)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task List_returns_only_non_archived_by_default_but_includes_with_flag() + { + await SeedAdminAsync(); + + // Create one normal + one to-be-archived. + await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Tarea viva")); + var archivedResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Tarea archivada")); + var archivedId = (await ReadAsync(archivedResp))!.Id; + await Client.PostAsync($"/api/household-tasks/{archivedId}/archive", content: null); + + var defaultResp = await Client.GetAsync("/api/household-tasks/"); + var defaultList = await ReadAsync>(defaultResp); + defaultList!.Select(t => t.Title).Should().BeEquivalentTo(["Tarea viva"]); + + var allResp = await Client.GetAsync("/api/household-tasks/?includeArchived=true"); + var allList = await ReadAsync>(allResp); + allList!.Select(t => t.Title).Should().BeEquivalentTo(["Tarea viva", "Tarea archivada"]); + } + + [Fact] + public async Task Complete_and_undo_round_trips_persistently() + { + await SeedAdminAsync(); + var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); + + var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Sacar la basura")); + var task = (await ReadAsync(createdResp))!; + + var done = await Client.PostAsync($"/api/household-tasks/{task.Id}/occurrences/{today}/complete", content: null); + done.StatusCode.Should().Be(HttpStatusCode.OK); + + var afterDone = await WithDbAsync(db => + db.TaskCompletions.AnyAsync(c => c.TaskId == task.Id)); + afterDone.Should().BeTrue(); + + var undone = await Client.PostAsync($"/api/household-tasks/{task.Id}/occurrences/{today}/undo", content: null); + undone.StatusCode.Should().Be(HttpStatusCode.OK); + + var afterUndone = await WithDbAsync(db => + db.TaskCompletions.AnyAsync(c => c.TaskId == task.Id)); + afterUndone.Should().BeFalse(); + } + + [Fact] + public async Task Today_view_surfaces_tasks_due_today_and_floating() + { + await SeedAdminAsync(); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + // One due today, one floating, one strictly in the future. The future + // task uses startDate == dueDate so it doesn't trigger deadline-range + // semantics (which would surface it every day in the window). + await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Hoy", dueDate: today)); + await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Flotante", isFloating: true)); + var futureDate = today.AddDays(1); + await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody( + "Maรฑana", startDate: futureDate, dueDate: futureDate)); + + var resp = await Client.GetAsync("/api/household-tasks/today"); + var day = await ReadAsync(resp); + var titles = day!.Tasks.Select(t => t.Task.Title).ToList(); + titles.Should().Contain(["Hoy", "Flotante"]); + titles.Should().NotContain("Maรฑana"); + } + + [Fact] + public async Task Deadline_range_appears_every_day_until_completed() + { + await SeedAdminAsync(); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + // StartDate < DueDate triggers the deadline-range semantics. + await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody( + "Lavar el coche", + startDate: today, + dueDate: today.AddDays(3))); + + // Today: shows up. + var dayResp = await Client.GetAsync("/api/household-tasks/today"); + var day = await ReadAsync(dayResp); + day!.Tasks.Should().Contain(t => t.Task.Title == "Lavar el coche"); + + // Complete the occurrence: from then on it must not appear in the future. + var task = day.Tasks.First(t => t.Task.Title == "Lavar el coche").Task; + await Client.PostAsync( + $"/api/household-tasks/{task.Id}/occurrences/{today:yyyy-MM-dd}/complete", content: null); + + // Same query again โ€” gone everywhere because deadline-range tasks are + // "done forever" once any completion exists, exactly like floating. + var afterResp = await Client.GetAsync("/api/household-tasks/today"); + var after = await ReadAsync(afterResp); + after!.Tasks.Should().NotContain(t => t.Task.Title == "Lavar el coche"); + } + + [Fact] + public async Task Archive_then_restore_round_trips() + { + await SeedAdminAsync(); + var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Cositas")); + var task = (await ReadAsync(createdResp))!; + + var archive = await Client.PostAsync($"/api/household-tasks/{task.Id}/archive", content: null); + archive.StatusCode.Should().Be(HttpStatusCode.OK); + (await WithDbAsync(db => db.HouseholdTasks.Where(t => t.Id == task.Id).Select(t => t.IsArchived).FirstAsync())) + .Should().BeTrue(); + + var restore = await Client.PostAsync($"/api/household-tasks/{task.Id}/restore", content: null); + restore.StatusCode.Should().Be(HttpStatusCode.OK); + (await WithDbAsync(db => db.HouseholdTasks.Where(t => t.Id == task.Id).Select(t => t.IsArchived).FirstAsync())) + .Should().BeFalse(); + } + + [Fact] + public async Task Admin_can_reattribute_a_completed_occurrence() + { + var admin = await SeedAdminAsync(); + var other = await WithDbAsync(db => TestSeed.SeedMemberAsync( + db, admin.Family.Id, "Marรญa", FamilyNido.Domain.Families.MemberType.Adult)); + var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); + + var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Tirar la basura")); + var task = (await ReadAsync(createdResp))!; + + // Admin completes the task themselves first. + var done = await Client.PostAsync($"/api/household-tasks/{task.Id}/occurrences/{today}/complete", content: null); + done.StatusCode.Should().Be(HttpStatusCode.OK); + + // Then re-attributes the completion to Marรญa. + var put = await Client.PutAsJsonAsync( + $"/api/household-tasks/{task.Id}/occurrences/{today}/completion", + new { completedByMemberId = other.Id, note = (string?)null }); + put.StatusCode.Should().Be(HttpStatusCode.OK); + + var stored = await WithDbAsync(db => + db.TaskCompletions.AsNoTracking().FirstAsync(c => c.TaskId == task.Id)); + stored.CompletedByMemberId.Should().Be(other.Id); + } + + [Fact] + public async Task Admin_can_create_a_completion_attributed_to_another_member() + { + var admin = await SeedAdminAsync(); + var other = await WithDbAsync(db => TestSeed.SeedMemberAsync( + db, admin.Family.Id, "Marรญa", FamilyNido.Domain.Families.MemberType.Adult)); + var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); + + var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Recoger paquete")); + var task = (await ReadAsync(createdResp))!; + + // No prior completion โ€” PUT should upsert one attributed to Marรญa. + var put = await Client.PutAsJsonAsync( + $"/api/household-tasks/{task.Id}/occurrences/{today}/completion", + new { completedByMemberId = other.Id, note = (string?)null }); + put.StatusCode.Should().Be(HttpStatusCode.OK); + + var stored = await WithDbAsync(db => + db.TaskCompletions.AsNoTracking().FirstAsync(c => c.TaskId == task.Id)); + stored.CompletedByMemberId.Should().Be(other.Id); + } + + [Fact] + public async Task Non_admin_cannot_reattribute_a_completion() + { + // Seed a family with an adult (non-admin) caller. + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync( + db, Fixture.Factory.Services, + email: "adult@example.com", + displayName: "Adulto", + role: FamilyNido.Domain.Families.FamilyRole.Adult)); + await TestSeed.LoginAsync(Client, "adult@example.com"); + var other = await WithDbAsync(db => TestSeed.SeedMemberAsync( + db, handle.Family.Id, "Marรญa", FamilyNido.Domain.Families.MemberType.Adult)); + var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); + + var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Limpiar baรฑo")); + var task = (await ReadAsync(createdResp))!; + + var put = await Client.PutAsJsonAsync( + $"/api/household-tasks/{task.Id}/occurrences/{today}/completion", + new { completedByMemberId = other.Id, note = (string?)null }); + put.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task Admin_attribution_to_unknown_member_returns_400() + { + await SeedAdminAsync(); + var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); + + var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Pasar aspirador")); + var task = (await ReadAsync(createdResp))!; + + var put = await Client.PutAsJsonAsync( + $"/api/household-tasks/{task.Id}/occurrences/{today}/completion", + new { completedByMemberId = Guid.NewGuid(), note = (string?)null }); + put.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Completions_history_returns_entries_sorted_descending() + { + var admin = await SeedAdminAsync(); + var other = await WithDbAsync(db => TestSeed.SeedMemberAsync( + db, admin.Family.Id, "Marรญa", FamilyNido.Domain.Families.MemberType.Adult)); + + // Daily task that started 5 days ago so the historical dates we + // attribute below are within the schedule (HasOccurrenceOn rejects + // anything before StartDate). + var startDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-5); + var createdResp = await Client.PostAsJsonAsync( + "/api/household-tasks/", + NewTaskBody("Sacar al perro", recurrence: "Daily", startDate: startDate)); + var task = (await ReadAsync(createdResp))!; + + // Three days, three different completers โ€” checks ordering and + // attribution round-trip. + var d0 = DateOnly.FromDateTime(DateTime.UtcNow); + var d1 = d0.AddDays(-1); + var d2 = d0.AddDays(-2); + + foreach (var (date, memberId) in new[] + { + (d2, admin.Member.Id), + (d1, other.Id), + (d0, admin.Member.Id), + }) + { + var put = await Client.PutAsJsonAsync( + $"/api/household-tasks/{task.Id}/occurrences/{date:yyyy-MM-dd}/completion", + new { completedByMemberId = memberId, note = (string?)null }); + put.StatusCode.Should().Be(HttpStatusCode.OK); + } + + var historyResp = await Client.GetAsync($"/api/household-tasks/{task.Id}/completions"); + historyResp.StatusCode.Should().Be(HttpStatusCode.OK); + var history = (await ReadAsync>(historyResp))!; + + history.Should().HaveCount(3); + history[0].OccurrenceDate.Should().Be(d0.ToString("yyyy-MM-dd")); + history[1].OccurrenceDate.Should().Be(d1.ToString("yyyy-MM-dd")); + history[2].OccurrenceDate.Should().Be(d2.ToString("yyyy-MM-dd")); + history[0].CompletedByMemberId.Should().Be(admin.Member.Id); + history[1].CompletedByMemberId.Should().Be(other.Id); + } + + private sealed record CompletionEntry(string OccurrenceDate, Guid? CompletedByMemberId, string CompletedAt, string? Note); + + /// Compact projection for deserialising task DTOs returned by the API. + private sealed record TaskRow(Guid Id, string Title, int Points); + + /// "Today" view shape returned by /api/household-tasks/today. + private sealed record DayTasks(string Date, List Tasks); + private sealed record TaskOnDate(TaskRow Task, TaskOccurrence Occurrence); + private sealed record TaskOccurrence(string TaskId, string OccurrenceDate, bool IsCompleted); +} diff --git a/tests/FamilyNido.Tests/Integration/InfrastructureSmokeTests.cs b/tests/FamilyNido.Tests/Integration/InfrastructureSmokeTests.cs new file mode 100644 index 0000000..ee5aa95 --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/InfrastructureSmokeTests.cs @@ -0,0 +1,55 @@ +using System.Net; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Tests.Integration; + +/// +/// Sanity checks for the integration-test infrastructure itself: +/// the Postgres container is reachable, migrations applied, the +/// WebApplicationFactory bootstraps, and the local-login + cookie pipeline +/// works end-to-end. Every other integration spec assumes these things; +/// keeping a smoke here makes "the infra broke" obvious from the failure list. +/// +public sealed class InfrastructureSmokeTests : IntegrationTestBase +{ + public InfrastructureSmokeTests(IntegrationFixture fixture) : base(fixture) { } + + [Fact] + public async Task Database_is_migrated_and_empty() + { + var familyCount = await WithDbAsync(db => db.Families.CountAsync()); + familyCount.Should().Be(0); + } + + [Fact] + public async Task Anonymous_request_to_protected_endpoint_returns_401() + { + var response = await Client.GetAsync("/api/family-members"); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Local_login_grants_an_authenticated_session_cookie() + { + // Seed a family + admin user with the default test password. + await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + + // Login via the same endpoint the front end uses. + await TestSeed.LoginAsync(Client, "dan@example.com"); + + // The cookie is now sticky on the client; protected endpoints succeed. + var response = await Client.GetAsync("/api/family-members"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Reset_between_tests_actually_truncates() + { + // This test relies on the previous test having seeded a family. By the + // time we run, IntegrationTestBase.InitializeAsync has called ResetAsync, + // so the table count must be back to zero. + var count = await WithDbAsync(db => db.Users.CountAsync()); + count.Should().Be(0); + } +} diff --git a/tests/FamilyNido.Tests/Integration/IntegrationApiKeyTests.cs b/tests/FamilyNido.Tests/Integration/IntegrationApiKeyTests.cs new file mode 100644 index 0000000..8f69173 --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/IntegrationApiKeyTests.cs @@ -0,0 +1,120 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using FamilyNido.Domain.Families; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Tests.Integration; + +/// +/// End-to-end coverage for the admin-side API-key management endpoints +/// under /api/integrations/api-keys/**. The machine-facing public +/// API surface (creating tasks, etc.) is covered separately in +/// . +/// +public sealed class IntegrationApiKeyTests : IntegrationTestBase +{ + public IntegrationApiKeyTests(IntegrationFixture fixture) : base(fixture) { } + + private async Task SeedAdminAsync() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + return handle; + } + + [Fact] + public async Task Create_returns_plaintext_token_once_and_persists_only_the_digest() + { + await SeedAdminAsync(); + + var resp = await Client.PostAsJsonAsync("/api/integrations/api-keys", new { name = "Test integration" }); + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var created = await ReadAsync(resp); + created!.Token.Should().StartWith("bxn_").And.HaveLength(47); + created.Key.Name.Should().Be("Test integration"); + created.Key.Prefix.Should().Be(created.Token[..12]); + created.Key.LastUsedAt.Should().BeNull(); + created.Key.RevokedAt.Should().BeNull(); + + // Persisted row keeps the digest, never the plaintext. + var saved = await WithDbAsync(db => db.IntegrationApiKeys.SingleAsync()); + saved.TokenHash.Should().HaveLength(64).And.NotContain(created.Token); + saved.Name.Should().Be("Test integration"); + } + + [Fact] + public async Task Create_is_admin_only() + { + await WithDbAsync(db => TestSeed.SeedFamilyAsync( + db, Fixture.Factory.Services, role: FamilyRole.Adult)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var resp = await Client.PostAsJsonAsync("/api/integrations/api-keys", new { name = "test" }); + resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task List_returns_keys_newest_first_and_hides_revoked_secrets() + { + await SeedAdminAsync(); + + await Client.PostAsJsonAsync("/api/integrations/api-keys", new { name = "first" }); + await Task.Delay(20); + await Client.PostAsJsonAsync("/api/integrations/api-keys", new { name = "second" }); + + var resp = await Client.GetAsync("/api/integrations/api-keys"); + var keys = await ReadAsync>(resp); + + keys.Should().HaveCount(2); + keys![0].Name.Should().Be("second"); + keys[1].Name.Should().Be("first"); + } + + [Fact] + public async Task Revoke_marks_the_token_and_subsequent_use_returns_401() + { + await SeedAdminAsync(); + + var createResp = await Client.PostAsJsonAsync("/api/integrations/api-keys", new { name = "to-be-revoked" }); + var created = await ReadAsync(createResp); + + // First, prove the token works against the public API surface. + var ok = await CallPublicApiAsync(created!.Token, new { title = "smoke", isFloating = true }); + ok.StatusCode.Should().Be(HttpStatusCode.Created); + + var revokeResp = await Client.PostAsync($"/api/integrations/api-keys/{created.Key.Id}/revoke", content: null); + revokeResp.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Now the same token must be rejected. + var failed = await CallPublicApiAsync(created.Token, new { title = "smoke", isFloating = true }); + failed.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + // โ”€โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + private async Task CallPublicApiAsync(string token, object body) + { + // Issue a fresh request without the cookie auth so the integration + // path is the only one being exercised. + using var anon = Fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + HandleCookies = false, + AllowAutoRedirect = false, + }); + anon.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return await anon.PostAsJsonAsync("/api/v1/tasks", body); + } + + private sealed record CreatedKeyDto(string Token, KeyDto Key); + private sealed record KeyDto( + Guid Id, + string Name, + string Prefix, + DateTimeOffset CreatedAt, + DateTimeOffset? LastUsedAt, + DateTimeOffset? RevokedAt); +} diff --git a/tests/FamilyNido.Tests/Integration/IntegrationFixture.cs b/tests/FamilyNido.Tests/Integration/IntegrationFixture.cs new file mode 100644 index 0000000..294f251 --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/IntegrationFixture.cs @@ -0,0 +1,145 @@ +using FamilyNido.Persistence; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace FamilyNido.Tests.Integration; + +/// +/// Single shared fixture for the whole integration-test suite. Owns: +/// +/// One Postgres container (TestContainers) โ€” schema migrated once at start. +/// One bound to that container. +/// A primitive that truncates every public table +/// between tests to keep them independent. +/// +/// xUnit will instantiate this fixture once per test collection (see +/// ) and dispose it at the end of the run. +/// +public sealed class IntegrationFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("FamilyNido") + .WithUsername("FamilyNido") + .WithPassword("FamilyNido") + .Build(); + + /// WebApplicationFactory bound to the Postgres container. + public FamilyNidoApiFactory Factory { get; private set; } = null!; + + /// List of public tables to truncate, captured once after migrations. + private string[] _tables = []; + + /// + public async Task InitializeAsync() + { + await _postgres.StartAsync(); + + Factory = new FamilyNidoApiFactory(_postgres.GetConnectionString()); + + // Triggering CreateClient() bootstraps the host, which also runs + // migrations because Family.AutoMigrate=true in appsettings.Testing.json. + using var bootstrap = Factory.CreateClient(); + + // Capture the live table list (schema is now stable). Used by ResetAsync. + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var conn = (NpgsqlConnection)db.Database.GetDbConnection(); + await conn.OpenAsync(); + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = """ + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' AND tablename <> '__EFMigrationsHistory' + """; + var rows = new List(); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + rows.Add(reader.GetString(0)); + } + _tables = [.. rows]; + } + } + + /// + public async Task DisposeAsync() + { + await Factory.DisposeAsync(); + await _postgres.DisposeAsync(); + } + + /// + /// Wipe every public table between tests so they observe a known state. + /// Cheaper than recreating the schema and works fine for our scale. + /// + public async Task ResetAsync() + { + if (_tables.Length == 0) return; + await using var scope = Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var conn = (NpgsqlConnection)db.Database.GetDbConnection(); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + var quoted = string.Join(", ", _tables.Select(t => $"\"{t}\"")); + cmd.CommandText = $"TRUNCATE TABLE {quoted} RESTART IDENTITY CASCADE"; + await cmd.ExecuteNonQueryAsync(); + } +} + +/// Marker collection so every integration test class shares the fixture above. +[CollectionDefinition(IntegrationCollection.Name)] +public sealed class IntegrationCollection : ICollectionFixture +{ + /// Collection name referenced by tests via [Collection(...)]. + public const string Name = "FamilyNido integration"; +} + +/// +/// Test-time WebApplicationFactory: rewires the Postgres connection string at +/// the configuration layer (rather than re-registering the DbContext) so the +/// real wiring stays +/// in effect โ€” including migrations + naming conventions. +/// +public sealed class FamilyNidoApiFactory(string connectionString) : WebApplicationFactory +{ + /// + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.UseSetting("ConnectionStrings:Postgres", connectionString); + + // Fail fast on missing test config files: the test project copies + // appsettings.Testing.json next to the assembly via the csproj. + builder.ConfigureAppConfiguration((context, cfg) => + { + var dir = AppContext.BaseDirectory; + cfg.AddJsonFile(Path.Combine(dir, "appsettings.Testing.json"), optional: false); + }); + + // The production setup wires OIDC as the default challenge scheme so + // the OnRedirectToLogin event on Cookie is the one that returns 401 + // for /api paths. Under test we have no real OIDC provider โ€” keeping + // OIDC as the challenge scheme means every anonymous /api hit tries + // to fetch a bogus metadata document and crashes with 500. Switching + // the default challenge to Cookie in the test environment is enough + // to get clean 401s; the local-login slice signs into the cookie + // scheme directly so it is not affected. + builder.ConfigureTestServices(services => + { + services.Configure(options => + { + options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; + }); + }); + } +} diff --git a/tests/FamilyNido.Tests/Integration/IntegrationTestBase.cs b/tests/FamilyNido.Tests/Integration/IntegrationTestBase.cs new file mode 100644 index 0000000..befb9e6 --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/IntegrationTestBase.cs @@ -0,0 +1,82 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using FamilyNido.Persistence; +using Microsoft.Extensions.DependencyInjection; + +namespace FamilyNido.Tests.Integration; + +/// +/// Common base for integration tests. Resets the DB before each test, exposes +/// helpers for getting an and an HTTP client, +/// and is opted into automatically so +/// concrete tests only need to inherit. +/// +[Collection(IntegrationCollection.Name)] +public abstract class IntegrationTestBase : IAsyncLifetime +{ + /// Shared fixture โ€” Postgres + WebApplicationFactory. + protected IntegrationFixture Fixture { get; } + + /// Test-scoped HTTP client. Cookies persist within the same test. + protected HttpClient Client { get; private set; } = null!; + + /// Constructor โ€” xUnit injects the fixture via the collection. + protected IntegrationTestBase(IntegrationFixture fixture) + { + Fixture = fixture; + } + + /// + public async Task InitializeAsync() + { + await Fixture.ResetAsync(); + Client = Fixture.Factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + // Cookies are essential โ€” local-login signs the session into one. + HandleCookies = true, + AllowAutoRedirect = false, + }); + } + + /// + public Task DisposeAsync() + { + Client?.Dispose(); + return Task.CompletedTask; + } + + /// + /// Run the supplied delegate inside a fresh DI scope with an + /// available โ€” used by tests to seed + /// data or assert persisted state. + /// + protected async Task WithDbAsync(Func> action) + { + await using var scope = Fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await action(db); + } + + /// Void overload of for seeding helpers. + protected async Task WithDbAsync(Func action) + { + await using var scope = Fixture.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await action(db); + } + + /// + /// JSON options that mirror the API: enums serialised as strings (the + /// API registers globally on + /// HttpJsonOptions). Tests use this when reading bodies. + /// + protected static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() }, + }; + + /// Read the response as using the API's JSON conventions. + protected static Task ReadAsync(HttpResponseMessage response) + => response.Content.ReadFromJsonAsync(JsonOptions); +} diff --git a/tests/FamilyNido.Tests/Integration/MealsTests.cs b/tests/FamilyNido.Tests/Integration/MealsTests.cs new file mode 100644 index 0000000..1a5a1a6 --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/MealsTests.cs @@ -0,0 +1,77 @@ +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Tests.Integration; + +/// +/// Coverage for /api/meals: weekly view, slot upsert (per-course), clearing +/// a slot, autocomplete suggestions and "duplicate previous week". +/// +public sealed class MealsTests : IntegrationTestBase +{ + public MealsTests(IntegrationFixture fixture) : base(fixture) { } + + [Fact] + public async Task Upsert_then_get_week_returns_the_dish() + { + await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var today = DateOnly.FromDateTime(DateTime.UtcNow); + // Find Monday of the current ISO week. + var dow = (int)today.DayOfWeek; + var daysFromMonday = dow == 0 ? 6 : dow - 1; + var monday = today.AddDays(-daysFromMonday); + + var resp = await Client.PutAsJsonAsync("/api/meals/slots", new + { + date = today.ToString("yyyy-MM-dd"), + slot = "Lunch", + course = "First", + name = "Carbonara", + }); + resp.IsSuccessStatusCode.Should().BeTrue(); + + var weekResp = await Client.GetAsync($"/api/meals/week?startDate={monday:yyyy-MM-dd}"); + var week = await ReadAsync(weekResp); + + var todayDay = week!.Days.FirstOrDefault(d => d.Date == today.ToString("yyyy-MM-dd")); + todayDay.Should().NotBeNull(); + todayDay!.Lunch.Should().NotBeNull(); + todayDay.Lunch!.FirstCourse.Should().Be("Carbonara"); + } + + [Fact] + public async Task Suggestions_returns_recently_used_names() + { + await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + // Register two dishes through the upsert flow. + await Client.PutAsJsonAsync("/api/meals/slots", new + { + date = today.ToString("yyyy-MM-dd"), + slot = "Lunch", + course = "First", + name = "Lentejas", + }); + await Client.PutAsJsonAsync("/api/meals/slots", new + { + date = today.AddDays(-1).ToString("yyyy-MM-dd"), + slot = "Dinner", + course = "Second", + name = "Tortilla", + }); + + var resp = await Client.GetAsync("/api/meals/suggestions?prefix=L"); + resp.IsSuccessStatusCode.Should().BeTrue(); + var content = await resp.Content.ReadAsStringAsync(); + content.Should().Contain("Lentejas"); + } + + private sealed record WeekDto(string WeekStart, List Days); + private sealed record DayDto(string Date, SlotDto? Lunch, SlotDto? Dinner); + private sealed record SlotDto(string? FirstCourse, string? SecondCourse); +} diff --git a/tests/FamilyNido.Tests/Integration/MemberAgendaTests.cs b/tests/FamilyNido.Tests/Integration/MemberAgendaTests.cs new file mode 100644 index 0000000..5926b7e --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/MemberAgendaTests.cs @@ -0,0 +1,216 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Tests.Integration; + +/// +/// Coverage for /api/member-agenda: pattern CRUD, ad-hoc + override +/// exceptions, and the overview resolver that merges them. The resolver +/// has the most edge cases of any read-side slice in the app, so this +/// suite is intentionally exhaustive. +/// +public sealed class MemberAgendaTests : IntegrationTestBase +{ + public MemberAgendaTests(IntegrationFixture fixture) : base(fixture) { } + + private async Task SeedAdminAsync() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + return handle; + } + + [Fact] + public async Task Create_pattern_persists_and_overview_returns_resolved_entries() + { + var handle = await SeedAdminAsync(); + + var create = await Client.PostAsJsonAsync("/api/member-agenda/patterns", new + { + memberId = handle.Member.Id, + dayOfWeek = "Tuesday", + label = "Mondragรณn", + location = "Mondragรณn", + startTime = "08:30:00", + endTime = "18:00:00", + transportMode = "Car", + isAway = true, + notes = (string?)null, + isActive = true, + }); + create.StatusCode.Should().Be(HttpStatusCode.OK); + + // Pick a Tuesday in range to assert resolution. Look at the next + // 14 days for a Tuesday so the test isn't day-of-week dependent. + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var tuesday = today; + for (var i = 0; i < 14; i++) + { + if (today.AddDays(i).DayOfWeek == DayOfWeek.Tuesday) + { + tuesday = today.AddDays(i); + break; + } + } + + var resp = await Client.GetAsync( + $"/api/member-agenda/overview?from={tuesday:yyyy-MM-dd}&to={tuesday:yyyy-MM-dd}"); + var overview = await ReadAsync(resp); + + overview!.Resolved.Should().Contain(r => + r.MemberId == handle.Member.Id && + r.Label == "Mondragรณn" && + r.IsAway); + } + + [Fact] + public async Task Override_exception_cancels_a_pattern_for_one_date() + { + var handle = await SeedAdminAsync(); + + var patternResp = await Client.PostAsJsonAsync("/api/member-agenda/patterns", new + { + memberId = handle.Member.Id, + dayOfWeek = "Tuesday", + label = "Mondragรณn", + transportMode = "Car", + isAway = true, + isActive = true, + location = (string?)null, + startTime = (string?)null, + endTime = (string?)null, + notes = (string?)null, + }); + var pattern = await ReadAsync(patternResp); + + // Find next Tuesday and cancel it via an override exception. + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var tuesday = today; + for (var i = 0; i < 14; i++) + { + if (today.AddDays(i).DayOfWeek == DayOfWeek.Tuesday) + { + tuesday = today.AddDays(i); + break; + } + } + + await Client.PostAsJsonAsync("/api/member-agenda/exceptions", new + { + memberId = handle.Member.Id, + date = tuesday.ToString("yyyy-MM-dd"), + patternId = pattern!.Id, + isCancelled = true, + label = (string?)null, + location = (string?)null, + startTime = (string?)null, + endTime = (string?)null, + transportMode = (string?)null, + isAway = (bool?)null, + notes = (string?)null, + }); + + var resp = await Client.GetAsync( + $"/api/member-agenda/overview?from={tuesday:yyyy-MM-dd}&to={tuesday:yyyy-MM-dd}"); + var overview = await ReadAsync(resp); + + // Cancelled โ€” must not appear in the resolved list. + overview!.Resolved.Should().NotContain(r => r.PatternId == pattern.Id); + } + + [Fact] + public async Task Adhoc_exception_appears_in_overview_for_that_single_date() + { + var handle = await SeedAdminAsync(); + var date = DateOnly.FromDateTime(DateTime.UtcNow); + + await Client.PostAsJsonAsync("/api/member-agenda/exceptions", new + { + memberId = handle.Member.Id, + date = date.ToString("yyyy-MM-dd"), + patternId = (Guid?)null, + isCancelled = false, + label = "Dรญa puntual", + location = "Madrid", + startTime = "10:00:00", + endTime = (string?)null, + transportMode = "Train", + isAway = true, + notes = (string?)null, + }); + + var resp = await Client.GetAsync( + $"/api/member-agenda/overview?from={date:yyyy-MM-dd}&to={date:yyyy-MM-dd}"); + var overview = await ReadAsync(resp); + + overview!.Resolved.Should().Contain(r => + r.PatternId == null && + r.Label == "Dรญa puntual" && + r.Location == "Madrid"); + } + + [Fact] + public async Task Delete_pattern_cascades_to_its_overrides() + { + var handle = await SeedAdminAsync(); + var date = DateOnly.FromDateTime(DateTime.UtcNow); + + var patternResp = await Client.PostAsJsonAsync("/api/member-agenda/patterns", new + { + memberId = handle.Member.Id, + dayOfWeek = "Monday", + label = "Gym", + transportMode = "Walk", + isAway = true, + isActive = true, + location = (string?)null, + startTime = (string?)null, + endTime = (string?)null, + notes = (string?)null, + }); + var pattern = await ReadAsync(patternResp); + + await Client.PostAsJsonAsync("/api/member-agenda/exceptions", new + { + memberId = handle.Member.Id, + date = date.ToString("yyyy-MM-dd"), + patternId = pattern!.Id, + isCancelled = true, + label = (string?)null, + location = (string?)null, + startTime = (string?)null, + endTime = (string?)null, + transportMode = (string?)null, + isAway = (bool?)null, + notes = (string?)null, + }); + + var del = await Client.DeleteAsync($"/api/member-agenda/patterns/{pattern.Id}"); + del.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Both pattern and the override must be gone. + var counts = await WithDbAsync(async db => new + { + Patterns = await db.MemberAgendaPatterns.CountAsync(), + Exceptions = await db.MemberAgendaExceptions.CountAsync(), + }); + counts.Patterns.Should().Be(0); + counts.Exceptions.Should().Be(0); + } + + private sealed record PatternDto(Guid Id); + private sealed record OverviewDto(string From, string To, List Resolved); + private sealed record ResolvedDto( + Guid MemberId, + string Date, + Guid? PatternId, + Guid? ExceptionId, + string Label, + string? Location, + string? StartTime, + string? EndTime, + string TransportMode, + bool IsAway); +} diff --git a/tests/FamilyNido.Tests/Integration/PreferencesTests.cs b/tests/FamilyNido.Tests/Integration/PreferencesTests.cs new file mode 100644 index 0000000..c598136 --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/PreferencesTests.cs @@ -0,0 +1,107 @@ +using System.Net.Http.Json; +using FluentAssertions; + +namespace FamilyNido.Tests.Integration; + +/// +/// Coverage for /api/dashboard/preferences and /api/notifications/preferences. +/// Both follow the same shape: GET returns a reconciled view, PUT replaces. +/// +public sealed class PreferencesTests : IntegrationTestBase +{ + public PreferencesTests(IntegrationFixture fixture) : base(fixture) { } + + [Fact] + public async Task Dashboard_GET_returns_default_widget_order_when_user_has_no_row() + { + await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var resp = await Client.GetAsync("/api/dashboard/preferences"); + resp.IsSuccessStatusCode.Should().BeTrue(); + + var prefs = await ReadAsync(resp); + // The default order from DashboardWidgets.DefaultOrder. + prefs!.Widgets.Should().HaveCountGreaterThanOrEqualTo(8); + prefs.Widgets.Select(w => w.Id).Should().Contain([ + "weather", "school", "agenda", "tasks", "calendar", + "meals", "wall", "scores", "birthdays", + ]); + prefs.Widgets.Should().AllSatisfy(w => w.Visible.Should().BeTrue()); + } + + [Fact] + public async Task Dashboard_PUT_persists_the_layout_and_GET_reflects_it() + { + await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var put = await Client.PutAsJsonAsync("/api/dashboard/preferences", new + { + widgets = new[] + { + new { id = "tasks", visible = true }, + new { id = "weather", visible = false }, + }, + }); + put.IsSuccessStatusCode.Should().BeTrue(); + + var get = await Client.GetAsync("/api/dashboard/preferences"); + var prefs = await ReadAsync(get); + + // The two ids the user sent are in the order they sent them; everything + // else is reconciled from the catalogue with Visible=true. + prefs!.Widgets[0].Id.Should().Be("tasks"); + prefs.Widgets[0].Visible.Should().BeTrue(); + prefs.Widgets[1].Id.Should().Be("weather"); + prefs.Widgets[1].Visible.Should().BeFalse(); + } + + [Fact] + public async Task Notifications_GET_returns_defaults() + { + await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var resp = await Client.GetAsync("/api/notifications/preferences"); + resp.IsSuccessStatusCode.Should().BeTrue(); + + var prefs = await ReadAsync(resp); + // Defaults: everything on (matches the persistence default). + prefs!.EmailEnabled.Should().BeTrue(); + prefs.DigestEnabled.Should().BeTrue(); + prefs.TaskAssignedEnabled.Should().BeTrue(); + prefs.WallMentionEnabled.Should().BeTrue(); + } + + [Fact] + public async Task Notifications_PUT_persists_toggle_changes() + { + await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var put = await Client.PutAsJsonAsync("/api/notifications/preferences", new + { + emailEnabled = true, + digestEnabled = false, + taskAssignedEnabled = true, + wallMentionEnabled = false, + }); + put.IsSuccessStatusCode.Should().BeTrue(); + + var get = await Client.GetAsync("/api/notifications/preferences"); + var prefs = await ReadAsync(get); + prefs!.DigestEnabled.Should().BeFalse(); + prefs.WallMentionEnabled.Should().BeFalse(); + prefs.TaskAssignedEnabled.Should().BeTrue(); + } + + private sealed record DashPrefsDto(List Widgets); + private sealed record WidgetDto(string Id, bool Visible); + + private sealed record NotifPrefsDto( + bool EmailEnabled, + bool DigestEnabled, + bool TaskAssignedEnabled, + bool WallMentionEnabled); +} diff --git a/tests/FamilyNido.Tests/Integration/PublicApiTests.cs b/tests/FamilyNido.Tests/Integration/PublicApiTests.cs new file mode 100644 index 0000000..139ed73 --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/PublicApiTests.cs @@ -0,0 +1,246 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using FamilyNido.Domain.HouseholdTasks; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Tests.Integration; + +/// +/// End-to-end coverage for the public API surface under /api/v1/**: +/// generic task creation by integrations holding a valid API key. +/// +public sealed class PublicApiTests : IntegrationTestBase +{ + public PublicApiTests(IntegrationFixture fixture) : base(fixture) { } + + private async Task<(TestSeed.FamilyHandle handle, string token)> SeedAndIssueTokenAsync( + string tokenName = "Test integration") + { + var handle = await WithDbAsync(db => + TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var resp = await Client.PostAsJsonAsync("/api/integrations/api-keys", new { name = tokenName }); + resp.IsSuccessStatusCode.Should().BeTrue(); + var created = await ReadAsync(resp); + return (handle, created!.Token); + } + + /// Issue a request that exercises the integration API-key path only (no cookie). + private async Task PostTaskAsync(string token, object body) + { + using var anon = Fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + HandleCookies = false, + AllowAutoRedirect = false, + }); + anon.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return await anon.PostAsJsonAsync("/api/v1/tasks", body); + } + + [Fact] + public async Task Post_without_auth_returns_401() + { + await SeedAndIssueTokenAsync(); + + var resp = await Client.PostAsJsonAsync("/api/v1/tasks", new { title = "test" }); + resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Post_with_unknown_token_returns_401() + { + var resp = await PostTaskAsync("bxn_thisisnotreal", new { title = "test" }); + resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Post_minimal_body_creates_a_floating_chore_today() + { + var (handle, token) = await SeedAndIssueTokenAsync(); + + var resp = await PostTaskAsync(token, new { title = "Comprar pan" }); + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var body = await ReadAsync(resp); + body!.Created.Should().BeTrue(); + body.Title.Should().Be("Comprar pan"); + + var saved = await WithDbAsync(db => db.HouseholdTasks.SingleAsync()); + saved.FamilyId.Should().Be(handle.Family.Id); + saved.CreatedByMemberId.Should().Be(handle.Member.Id); + saved.Title.Should().Be("Comprar pan"); + saved.Category.Should().Be("General"); + saved.Points.Should().Be(5); + saved.IsFloating.Should().BeFalse(); + // Single-shot non-floating task with no DueDate supplied โ†’ falls back to today. + saved.DueDate.Should().Be(DateOnly.FromDateTime(DateTime.UtcNow)); + saved.Recurrence.Should().Be(RecurrenceMode.None); + saved.ResponsibleMemberId.Should().BeNull(); + } + + [Fact] + public async Task Post_with_isFloating_true_creates_a_floating_task() + { + var (_, token) = await SeedAndIssueTokenAsync(); + + var resp = await PostTaskAsync(token, new + { + title = "Vaciar el lavavajillas", + category = "Hogar", + points = 3, + isFloating = true, + }); + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var saved = await WithDbAsync(db => db.HouseholdTasks.SingleAsync()); + saved.Category.Should().Be("Hogar"); + saved.Points.Should().Be(3); + saved.IsFloating.Should().BeTrue(); + saved.DueDate.Should().BeNull(); + } + + [Fact] + public async Task Post_with_explicit_dueDate_honours_it() + { + var (_, token) = await SeedAndIssueTokenAsync(); + var due = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(3); + + var resp = await PostTaskAsync(token, new + { + title = "Renovar DNI", + dueDate = due.ToString("yyyy-MM-dd"), + }); + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var saved = await WithDbAsync(db => db.HouseholdTasks.SingleAsync()); + saved.DueDate.Should().Be(due); + saved.IsFloating.Should().BeFalse(); + } + + [Fact] + public async Task Post_with_deduplicate_true_returns_200_on_second_call() + { + var (_, token) = await SeedAndIssueTokenAsync(); + + var first = await PostTaskAsync(token, new + { + title = "Vaciar el lavavajillas", + isFloating = true, + deduplicate = true, + }); + first.StatusCode.Should().Be(HttpStatusCode.Created); + var firstBody = await ReadAsync(first); + + var second = await PostTaskAsync(token, new + { + title = "Vaciar el lavavajillas", + isFloating = true, + deduplicate = true, + }); + second.StatusCode.Should().Be(HttpStatusCode.OK); + var secondBody = await ReadAsync(second); + secondBody!.Created.Should().BeFalse(); + secondBody.Reason.Should().Be("already-pending"); + secondBody.TaskId.Should().Be(firstBody!.TaskId); + + var count = await WithDbAsync(db => db.HouseholdTasks.CountAsync()); + count.Should().Be(1); + } + + [Fact] + public async Task Post_with_deduplicate_creates_new_task_after_previous_was_completed() + { + var (handle, token) = await SeedAndIssueTokenAsync(); + + await PostTaskAsync(token, new + { + title = "Tender la lavadora", + isFloating = true, + deduplicate = true, + }); + + // Mark the floating task done so its "pending" status flips. + await WithDbAsync(async db => + { + var task = await db.HouseholdTasks.SingleAsync(); + db.TaskCompletions.Add(new TaskCompletion + { + TaskId = task.Id, + CompletedByMemberId = handle.Member.Id, + OccurrenceDate = DateOnly.FromDateTime(DateTime.UtcNow), + CompletedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + }); + + var second = await PostTaskAsync(token, new + { + title = "Tender la lavadora", + isFloating = true, + deduplicate = true, + }); + second.StatusCode.Should().Be(HttpStatusCode.Created); + + var count = await WithDbAsync(db => db.HouseholdTasks.CountAsync()); + count.Should().Be(2); + } + + [Fact] + public async Task Post_with_deduplicate_false_always_creates_a_new_row() + { + var (_, token) = await SeedAndIssueTokenAsync(); + + await PostTaskAsync(token, new { title = "Recoger paquete", isFloating = true }); + await PostTaskAsync(token, new { title = "Recoger paquete", isFloating = true }); + + var count = await WithDbAsync(db => db.HouseholdTasks.CountAsync()); + count.Should().Be(2); + } + + [Fact] + public async Task Post_with_responsibleMemberId_outside_the_family_returns_400() + { + var (_, token) = await SeedAndIssueTokenAsync(); + var foreignMemberId = Guid.NewGuid(); + + var resp = await PostTaskAsync(token, new + { + title = "Recoger paquete", + isFloating = true, + responsibleMemberId = foreignMemberId, + }); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Post_with_invalid_points_returns_400() + { + var (_, token) = await SeedAndIssueTokenAsync(); + + var resp = await PostTaskAsync(token, new { title = "x", points = -5 }); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Post_with_empty_title_returns_400() + { + var (_, token) = await SeedAndIssueTokenAsync(); + + var resp = await PostTaskAsync(token, new { title = "" }); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + private sealed record CreatedKeyDto(string Token, KeyDto Key); + private sealed record KeyDto( + Guid Id, + string Name, + string Prefix, + DateTimeOffset CreatedAt, + DateTimeOffset? LastUsedAt, + DateTimeOffset? RevokedAt); + private sealed record TaskResponse(bool Created, string? Reason, Guid TaskId, string Title); +} diff --git a/tests/FamilyNido.Tests/Integration/SchoolTests.cs b/tests/FamilyNido.Tests/Integration/SchoolTests.cs new file mode 100644 index 0000000..6807def --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/SchoolTests.cs @@ -0,0 +1,86 @@ +using System.Net.Http.Json; +using FluentAssertions; + +namespace FamilyNido.Tests.Integration; + +/// +/// Coverage for /api/school: profile upsert, holidays, weekly day-schedule +/// patterns and per-date exceptions, plus the overview resolver that merges +/// them into resolved per-day rows. +/// +public sealed class SchoolTests : IntegrationTestBase +{ + public SchoolTests(IntegrationFixture fixture) : base(fixture) { } + + [Fact] + public async Task Holiday_added_then_listed_in_overview() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var add = await Client.PostAsJsonAsync("/api/school/holidays", new + { + startDate = today.ToString("yyyy-MM-dd"), + endDate = today.AddDays(2).ToString("yyyy-MM-dd"), + label = "Festivo de prueba", + }); + add.IsSuccessStatusCode.Should().BeTrue(); + + var resp = await Client.GetAsync( + $"/api/school/overview?from={today:yyyy-MM-dd}&to={today.AddDays(2):yyyy-MM-dd}"); + var overview = await ReadAsync(resp); + overview!.Holidays.Should().Contain(h => h.Label == "Festivo de prueba"); + } + + [Fact] + public async Task Day_schedule_pattern_persists_for_a_kid() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + // A child member to schedule. + var bob = await WithDbAsync(db => TestSeed.SeedMemberAsync(db, handle.Family.Id, "Bob")); + + var resp = await Client.PutAsJsonAsync( + $"/api/school/members/{bob.Id}/day-schedule", + new + { + slots = new[] + { + new + { + dayOfWeek = "Monday", + dropoffMemberId = (Guid?)handle.Member.Id, + pickupMemberId = (Guid?)null, + }, + }, + }); + resp.IsSuccessStatusCode.Should().BeTrue(); + + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var monday = today; + for (var i = 0; i < 14; i++) + { + if (today.AddDays(i).DayOfWeek == DayOfWeek.Monday) + { + monday = today.AddDays(i); + break; + } + } + var ovResp = await Client.GetAsync( + $"/api/school/overview?from={monday:yyyy-MM-dd}&to={monday:yyyy-MM-dd}"); + var ov = await ReadAsync(ovResp); + ov!.ResolvedDays.Should().Contain(d => d.MemberId == bob.Id && d.DropoffMemberId == handle.Member.Id); + } + + private sealed record OverviewDto( + List Holidays, + List ResolvedDays); + private sealed record HolidayDto(Guid Id, string StartDate, string EndDate, string Label); + private sealed record ResolvedDayDto( + Guid MemberId, + string Date, + Guid? DropoffMemberId, + Guid? PickupMemberId); +} diff --git a/tests/FamilyNido.Tests/Integration/ScoresTests.cs b/tests/FamilyNido.Tests/Integration/ScoresTests.cs new file mode 100644 index 0000000..bae206b --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/ScoresTests.cs @@ -0,0 +1,110 @@ +using System.Net.Http.Json; +using FamilyNido.Domain.HouseholdTasks; +using FluentAssertions; + +namespace FamilyNido.Tests.Integration; + +/// +/// Coverage for the family scoreboard slices: leaderboard aggregation and +/// per-member totals (this week / this month / all time). Seeds tasks + +/// completions directly through EF so we don't need to drive the UI flow +/// for every fixture variation. +/// +public sealed class ScoresTests : IntegrationTestBase +{ + public ScoresTests(IntegrationFixture fixture) : base(fixture) { } + + [Fact] + public async Task Leaderboard_sums_points_per_member_for_the_range() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var alice = await WithDbAsync(db => TestSeed.SeedMemberAsync(db, handle.Family.Id, "Alice")); + + // Dan: 5 points (one task), Alice: 12 points (two tasks). + var today = DateOnly.FromDateTime(DateTime.UtcNow); + await WithDbAsync(async db => + { + var t1 = new HouseholdTask + { + FamilyId = handle.Family.Id, + Title = "Tarea 5", + StartDate = today, + DueDate = today, + CreatedByMemberId = handle.Member.Id, + Points = 5, + }; + var t2 = new HouseholdTask + { + FamilyId = handle.Family.Id, + Title = "Tarea 7", + StartDate = today, + DueDate = today, + CreatedByMemberId = handle.Member.Id, + Points = 7, + }; + var t3 = new HouseholdTask + { + FamilyId = handle.Family.Id, + Title = "Tarea 5b", + StartDate = today, + DueDate = today, + CreatedByMemberId = handle.Member.Id, + Points = 5, + }; + db.HouseholdTasks.AddRange(t1, t2, t3); + await db.SaveChangesAsync(); + + db.TaskCompletions.AddRange( + new TaskCompletion + { + TaskId = t1.Id, OccurrenceDate = today, + CompletedByMemberId = handle.Member.Id, + CompletedAt = DateTimeOffset.UtcNow, + }, + new TaskCompletion + { + TaskId = t2.Id, OccurrenceDate = today, + CompletedByMemberId = alice.Id, + CompletedAt = DateTimeOffset.UtcNow, + }, + new TaskCompletion + { + TaskId = t3.Id, OccurrenceDate = today, + CompletedByMemberId = alice.Id, + CompletedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + }); + + var resp = await Client.GetAsync($"/api/scores/leaderboard?from={today:yyyy-MM-dd}&to={today:yyyy-MM-dd}"); + var board = await ReadAsync(resp); + + board!.Entries.Should().HaveCount(2); + board.Entries[0].MemberId.Should().Be(alice.Id); + board.Entries[0].Points.Should().Be(12); + board.Entries[0].CompletionCount.Should().Be(2); + board.Entries[1].MemberId.Should().Be(handle.Member.Id); + board.Entries[1].Points.Should().Be(5); + } + + [Fact] + public async Task Member_score_returns_zeros_when_no_completions() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + + var resp = await Client.GetAsync($"/api/scores/members/{handle.Member.Id}"); + var score = await ReadAsync(resp); + + score!.MemberId.Should().Be(handle.Member.Id); + score.ThisWeek.Should().Be(0); + score.ThisMonth.Should().Be(0); + score.AllTime.Should().Be(0); + } + + private sealed record LeaderboardDto(string From, string To, List Entries); + private sealed record ScoreboardEntry(Guid MemberId, int Points, int CompletionCount); + private sealed record MemberScoreDto(Guid MemberId, int ThisWeek, int ThisMonth, int AllTime); +} diff --git a/tests/FamilyNido.Tests/Integration/TestSeed.cs b/tests/FamilyNido.Tests/Integration/TestSeed.cs new file mode 100644 index 0000000..a04894a --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/TestSeed.cs @@ -0,0 +1,99 @@ +using System.Net.Http.Json; +using FamilyNido.Domain.Families; +using FamilyNido.Domain.Identity; +using FamilyNido.Persistence; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace FamilyNido.Tests.Integration; + +/// +/// Helpers for assembling the minimum domain graph an integration test needs: +/// a family, a user with a known local password, and one or more linked +/// members. Returned by reference so tests can chain seeding calls and assert +/// the persisted state. +/// +public static class TestSeed +{ + /// Default password used by every seeded user. Hashed with PBKDF2 v3 by the password hasher. + public const string DefaultPassword = "Test1234!"; + + /// Compact bundle returned by . + public sealed record FamilyHandle(Family Family, FamilyMember Member, User User); + + /// + /// Persist a family + an authenticable user with a linked member. Defaults + /// produce an admin adult; pass to vary. + /// + public static async Task SeedFamilyAsync( + ApplicationDbContext db, + IServiceProvider services, + string email = "dan@example.com", + string displayName = "Dan", + FamilyRole role = FamilyRole.Admin, + string colorHex = "#C96442") + { + var hasher = services.GetRequiredService>(); + + var family = new Family { Name = "Familia Test", TimeZone = "Europe/Madrid" }; + db.Families.Add(family); + + var user = new User { Email = email, DisplayName = displayName, Role = role }; + db.Users.Add(user); + + var member = new FamilyMember + { + FamilyId = family.Id, + DisplayName = displayName, + MemberType = MemberType.Adult, + ColorHex = colorHex, + UserId = user.Id, + }; + db.FamilyMembers.Add(member); + + var credential = new UserCredential + { + UserId = user.Id, + Provider = IdentityProvider.Local, + PasswordHash = hasher.HashPassword(user, DefaultPassword), + }; + db.UserCredentials.Add(credential); + + await db.SaveChangesAsync(); + return new FamilyHandle(family, member, user); + } + + /// Append one extra member (not authenticable) to an existing family. + public static async Task SeedMemberAsync( + ApplicationDbContext db, + Guid familyId, + string displayName, + MemberType type = MemberType.Child, + string colorHex = "#7BA4A8", + DateOnly? birthDate = null) + { + var member = new FamilyMember + { + FamilyId = familyId, + DisplayName = displayName, + MemberType = type, + ColorHex = colorHex, + BirthDate = birthDate, + }; + db.FamilyMembers.Add(member); + await db.SaveChangesAsync(); + return member; + } + + /// + /// Sign the supplied client in via POST /api/auth/local/login. The + /// session cookie is preserved on the client because HandleCookies + /// is enabled on the test factory. + /// + public static async Task LoginAsync(HttpClient client, string email, string password = DefaultPassword) + { + var response = await client.PostAsJsonAsync("/api/auth/local/login", new { email, password }); + response.IsSuccessStatusCode.Should().BeTrue($"login should succeed (got {response.StatusCode})"); + } +} diff --git a/tests/FamilyNido.Tests/Integration/WallTests.cs b/tests/FamilyNido.Tests/Integration/WallTests.cs new file mode 100644 index 0000000..250de5a --- /dev/null +++ b/tests/FamilyNido.Tests/Integration/WallTests.cs @@ -0,0 +1,191 @@ +using System.Net; +using System.Net.Http.Json; +using FamilyNido.Domain.Families; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace FamilyNido.Tests.Integration; + +/// +/// Coverage for /api/wall: create, update, delete, comment, reaction toggle, +/// pin/unpin, markdown preview. Permission rules around author-or-admin are +/// also exercised. +/// +public sealed class WallTests : IntegrationTestBase +{ + public WallTests(IntegrationFixture fixture) : base(fixture) { } + + private async Task SeedAdminAsync() + { + var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); + await TestSeed.LoginAsync(Client, "dan@example.com"); + return handle; + } + + [Fact] + public async Task Create_persists_a_message_and_renders_html() + { + await SeedAdminAsync(); + + var resp = await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Hola **mundo**" }); + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var dto = await ReadAsync(resp); + dto!.Text.Should().Contain("Hola"); + dto.TextHtml.Should().Contain(""); + } + + [Fact] + public async Task Update_changes_text_when_caller_is_author() + { + await SeedAdminAsync(); + var created = await ReadAsync( + await Client.PostAsJsonAsync("/api/wall/messages", new { text = "v1" })); + + var resp = await Client.PatchAsJsonAsync( + $"/api/wall/messages/{created!.Id}", + new { text = "v2 editado" }); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + + var saved = await WithDbAsync(db => + db.WallMessages.Where(m => m.Id == created.Id).Select(m => m.Text).FirstAsync()); + saved.Should().Contain("v2 editado"); + } + + [Fact] + public async Task Update_rejects_non_author_non_admin() + { + // Dan (admin) creates the message. + var dan = await SeedAdminAsync(); + var created = await ReadAsync( + await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Original de Dan" })); + + // Create a second user (Adult) who is not the author and not admin. + await WithDbAsync(db => TestSeed.SeedFamilyAsync( + db, Fixture.Factory.Services, + email: "alice@example.com", + displayName: "Alice", + role: FamilyRole.Adult)); + // The second seed created a *new* family โ€” back the test out and put + // Alice in Dan's family instead so the message is visible. + await WithDbAsync(async db => + { + var alice = await db.Users.SingleAsync(u => u.Email == "alice@example.com"); + var aliceMember = await db.FamilyMembers.SingleAsync(m => m.UserId == alice.Id); + aliceMember.FamilyId = dan.Family.Id; + // Drop the second family so it doesn't pollute future queries. + var spareFamily = await db.Families.SingleAsync(f => f.Id != dan.Family.Id); + db.Families.Remove(spareFamily); + await db.SaveChangesAsync(); + }); + + // Re-login as Alice. + var aliceClient = Fixture.Factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + HandleCookies = true, + AllowAutoRedirect = false, + }); + await TestSeed.LoginAsync(aliceClient, "alice@example.com"); + + var resp = await aliceClient.PatchAsJsonAsync( + $"/api/wall/messages/{created!.Id}", + new { text = "Hijack" }); + resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task Delete_removes_the_message() + { + await SeedAdminAsync(); + var created = await ReadAsync( + await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Para borrar" })); + + var resp = await Client.DeleteAsync($"/api/wall/messages/{created!.Id}"); + resp.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var stillThere = await WithDbAsync(db => + db.WallMessages.AnyAsync(m => m.Id == created.Id)); + stillThere.Should().BeFalse(); + } + + [Fact] + public async Task Pin_then_unpin_round_trips() + { + await SeedAdminAsync(); + var created = await ReadAsync( + await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Para fijar" })); + + var pin = await Client.PostAsync($"/api/wall/messages/{created!.Id}/pin", content: null); + pin.StatusCode.Should().Be(HttpStatusCode.OK); + (await WithDbAsync(db => + db.WallMessages.Where(m => m.Id == created.Id).Select(m => m.IsPinned).FirstAsync())) + .Should().BeTrue(); + + var unpin = await Client.PostAsync($"/api/wall/messages/{created.Id}/unpin", content: null); + unpin.StatusCode.Should().Be(HttpStatusCode.OK); + (await WithDbAsync(db => + db.WallMessages.Where(m => m.Id == created.Id).Select(m => m.IsPinned).FirstAsync())) + .Should().BeFalse(); + } + + [Fact] + public async Task Comment_persists_and_delete_removes_it() + { + await SeedAdminAsync(); + var msg = await ReadAsync( + await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Padre" })); + + var addResp = await Client.PostAsJsonAsync( + $"/api/wall/messages/{msg!.Id}/comments", + new { text = "Comentario" }); + addResp.IsSuccessStatusCode.Should().BeTrue(); + + var comment = await ReadAsync(addResp); + comment!.Text.Should().Contain("Comentario"); + + var del = await Client.DeleteAsync($"/api/wall/comments/{comment.Id}"); + del.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var stillThere = await WithDbAsync(db => + db.WallComments.AnyAsync(c => c.Id == comment.Id)); + stillThere.Should().BeFalse(); + } + + [Fact] + public async Task Toggle_reaction_adds_then_removes() + { + await SeedAdminAsync(); + var msg = await ReadAsync( + await Client.PostAsJsonAsync("/api/wall/messages", new { text = "Reactionable" })); + + var first = await Client.PostAsJsonAsync( + $"/api/wall/messages/{msg!.Id}/reactions", + new { emoji = "โค๏ธ" }); + first.StatusCode.Should().Be(HttpStatusCode.OK); + (await WithDbAsync(db => + db.WallReactions.CountAsync(r => r.MessageId == msg.Id))).Should().Be(1); + + var second = await Client.PostAsJsonAsync( + $"/api/wall/messages/{msg.Id}/reactions", + new { emoji = "โค๏ธ" }); + second.StatusCode.Should().Be(HttpStatusCode.OK); + (await WithDbAsync(db => + db.WallReactions.CountAsync(r => r.MessageId == msg.Id))).Should().Be(0); + } + + [Fact] + public async Task Preview_renders_markdown_with_strong() + { + await SeedAdminAsync(); + + var resp = await Client.PostAsJsonAsync("/api/wall/preview", new { text = "Hola **mundo**" }); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + + var dto = await ReadAsync(resp); + dto!.Html.Should().Contain("mundo"); + } + + private sealed record MessageRow(Guid Id, string Text, string TextHtml, bool IsPinned); + private sealed record CommentRow(Guid Id, Guid MessageId, string Text); + private sealed record PreviewDto(string Html); +} diff --git a/tests/FamilyNido.Tests/SolutionSmokeTests.cs b/tests/FamilyNido.Tests/SolutionSmokeTests.cs new file mode 100644 index 0000000..ce30c96 --- /dev/null +++ b/tests/FamilyNido.Tests/SolutionSmokeTests.cs @@ -0,0 +1,9 @@ +namespace FamilyNido.Tests; + +/// Smoke test to keep the project compiling. Real tests land with each module. +public sealed class SolutionSmokeTests +{ + /// Sanity check that xUnit is wired up. + [Fact] + public void Xunit_Is_Wired() => Assert.True(true); +} diff --git a/tests/FamilyNido.Tests/Wall/MarkdownRendererTests.cs b/tests/FamilyNido.Tests/Wall/MarkdownRendererTests.cs new file mode 100644 index 0000000..669ec1d --- /dev/null +++ b/tests/FamilyNido.Tests/Wall/MarkdownRendererTests.cs @@ -0,0 +1,86 @@ +using FamilyNido.Api.Shared.Markdown; +using FluentAssertions; + +namespace FamilyNido.Tests.Wall; + +/// +/// Exercises the safety properties of . In particular +/// verifies that raw HTML is escaped rather than passed through โ€” that is the +/// contract the wall relies on for RF-WALL-011. +/// +public sealed class MarkdownRendererTests +{ + private readonly MarkdownRenderer _renderer = new(); + + [Fact] + public void Emphasis_is_rendered() + { + var (md, html) = _renderer.Render("Hola **nido**"); + + md.Should().Be("Hola **nido**"); + html.Should().Contain("nido"); + } + + [Fact] + public void Soft_line_break_is_treated_as_hard_break() + { + var (_, html) = _renderer.Render("linea1\nlinea2"); + + html.Should().Contain("alert('x')"); + + html.Should().NotContain("