Initial commit

FamilyNido — a self-hosted PWA for a single household: shared calendar,
chores, meals, school agenda, health records and a family wall. One
instance per family, deployable with `docker compose` on any home
server.

Stack: .NET 10 (ASP.NET Core Minimal APIs) + EF Core 10 + PostgreSQL 16
on the backend, Angular 21 (standalone, signals, zoneless) + Tailwind
CSS v4 on the frontend, SignalR for realtime, optional OIDC alongside
local credentials, integration via a versioned `/api/v1/**` public API.

See README.md for the module overview and how to deploy.
This commit is contained in:
Pablo Fernández 2026-05-13 00:14:26 +02:00
commit a308041d59
492 changed files with 66240 additions and 0 deletions

38
.dockerignore Normal file
View file

@ -0,0 +1,38 @@
# Keep the build context lean for docker builds at the repo root.
**/.git
**/.gitignore
**/.gitattributes
**/.github
**/README.md
**/*.md
!CLAUDE.md
# .NET build artifacts
**/bin
**/obj
artifacts/
coverage/
TestResults/
# Node / Angular
**/node_modules
**/dist
**/.angular
**/.cache
# Editor
**/.vs
**/.vscode
**/.idea
# Env files — never baked into images
.env
**/.env
**/.env.local
**/.env.*.local
# Design bundle (reference only, not needed at build time)
design/
# Compose artifacts
.docker-data/

389
.editorconfig Normal file
View file

@ -0,0 +1,389 @@
root = true
# All files
[*]
indent_style = space
# Xml files
[*.xml]
indent_size = 2
# Xml project files
[*.{csproj,fsproj,vbproj,proj,slnx}]
indent_size = 2
# Xml config files
[*.{props,targets,config,nuspec}]
indent_size = 2
[*.json]
indent_size = 2
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
tab_width = 4
# New line preferences
insert_final_newline = false
#### .NET Coding Conventions ####
[*.{cs,vb}]
# Organize usings
dotnet_separate_import_directive_groups = true
dotnet_sort_system_directives_first = true
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = false:silent
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_method = false:silent
dotnet_style_qualification_for_property = false:silent
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_predefined_type_for_member_access = true:silent
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
# Expression-level preferences
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_namespace_match_folder = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
# Field preferences
dotnet_style_readonly_field = true:warning
# Parameter preferences
dotnet_code_quality_unused_parameters = all:suggestion
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
#### C# Coding Conventions ####
[*.cs]
# var preferences
csharp_style_var_elsewhere = false:silent
csharp_style_var_for_built_in_types = false:silent
csharp_style_var_when_type_is_apparent = false:silent
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:suggestion
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_extended_property_pattern = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_pattern_matching = true:silent
csharp_style_prefer_switch_expression = true:suggestion
# Null-checking preferences
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences
csharp_prefer_static_anonymous_function = true:suggestion
csharp_prefer_static_local_function = true:warning
csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion
csharp_style_prefer_readonly_struct = true:suggestion
csharp_style_prefer_readonly_struct_member = true:suggestion
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:suggestion
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_prefer_top_level_statements = true:silent
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:silent
#### C# Formatting Rules ####
# New line preferences
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Naming styles ####
[*.{cs,vb}]
# Naming rules
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.events_should_be_pascalcase.symbols = events
dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
# Symbol specifications
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interfaces.required_modifiers =
dotnet_naming_symbols.enums.applicable_kinds = enum
dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.enums.required_modifiers =
dotnet_naming_symbols.events.applicable_kinds = event
dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.events.required_modifiers =
dotnet_naming_symbols.methods.applicable_kinds = method
dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.methods.required_modifiers =
dotnet_naming_symbols.properties.applicable_kinds = property
dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.properties.required_modifiers =
dotnet_naming_symbols.public_fields.applicable_kinds = field
dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_fields.required_modifiers =
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_fields.required_modifiers = static
dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types_and_namespaces.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
dotnet_naming_symbols.type_parameters.required_modifiers =
dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_constant_fields.required_modifiers = const
dotnet_naming_symbols.local_variables.applicable_kinds = local
dotnet_naming_symbols.local_variables.applicable_accessibilities = local
dotnet_naming_symbols.local_variables.required_modifiers =
dotnet_naming_symbols.local_constants.applicable_kinds = local
dotnet_naming_symbols.local_constants.applicable_accessibilities = local
dotnet_naming_symbols.local_constants.required_modifiers = const
dotnet_naming_symbols.parameters.applicable_kinds = parameter
dotnet_naming_symbols.parameters.applicable_accessibilities = *
dotnet_naming_symbols.parameters.required_modifiers =
dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_constant_fields.required_modifiers = const
dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
dotnet_naming_symbols.local_functions.applicable_accessibilities = *
dotnet_naming_symbols.local_functions.required_modifiers =
# Naming styles
dotnet_naming_style.pascalcase.required_prefix =
dotnet_naming_style.pascalcase.required_suffix =
dotnet_naming_style.pascalcase.word_separator =
dotnet_naming_style.pascalcase.capitalization = pascal_case
dotnet_naming_style.ipascalcase.required_prefix = I
dotnet_naming_style.ipascalcase.required_suffix =
dotnet_naming_style.ipascalcase.word_separator =
dotnet_naming_style.ipascalcase.capitalization = pascal_case
dotnet_naming_style.tpascalcase.required_prefix = T
dotnet_naming_style.tpascalcase.required_suffix =
dotnet_naming_style.tpascalcase.word_separator =
dotnet_naming_style.tpascalcase.capitalization = pascal_case
dotnet_naming_style._camelcase.required_prefix = _
dotnet_naming_style._camelcase.required_suffix =
dotnet_naming_style._camelcase.word_separator =
dotnet_naming_style._camelcase.capitalization = camel_case
dotnet_naming_style.camelcase.required_prefix =
dotnet_naming_style.camelcase.required_suffix =
dotnet_naming_style.camelcase.word_separator =
dotnet_naming_style.camelcase.capitalization = camel_case
dotnet_naming_style.s_camelcase.required_prefix = s_
dotnet_naming_style.s_camelcase.required_suffix =
dotnet_naming_style.s_camelcase.word_separator =
dotnet_naming_style.s_camelcase.capitalization = camel_case

76
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,76 @@
name: 🐛 Bug report
description: Something is broken or behaving unexpectedly.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug report. Please fill in the
fields below — the more specific the reproduction, the faster this
moves. **Do not file security issues here** — use the private
*Report a vulnerability* button on the Security tab.
- type: textarea
id: summary
attributes:
label: What happened?
description: A clear and concise description of the bug.
placeholder: When I click X, the page errors with Y instead of doing Z.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Numbered list. Include the exact data or fixtures you used.
placeholder: |
1. Log in as an adult member
2. Navigate to /tasks
3. Click "New task" and submit with an empty title
4. Observe the 500 error in the network tab
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
placeholder: A 400 with a validation message, not a 500.
validations:
required: true
- type: input
id: deploy
attributes:
label: Deploy target
description: How are you running FamilyNido?
placeholder: docker compose on Debian 12 / native dev (dotnet run + ng serve) / …
validations:
required: true
- type: input
id: version
attributes:
label: Git commit / version
description: Output of `git rev-parse --short HEAD` from your checkout, or the image tag you pulled.
placeholder: e.g. 2f3dcc7
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs and screenshots
description: Relevant log lines (API + browser console) and a screenshot if visual. Redact personal data before pasting.
render: shell
validations:
required: false
- type: textarea
id: extra
attributes:
label: Anything else?
description: Custom configuration, integrations enabled, edge cases — anything that might explain the behavior.
validations:
required: false

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,11 @@
# NOTE: update the URLs below to match the actual <owner>/<repo> slug of the
# public repository after you create it on GitHub. They are templated against
# pablitofernandez/FamilyNido as a starting point.
blank_issues_enabled: false
contact_links:
- name: 🔒 Security vulnerability
url: https://github.com/pablitofernandez/FamilyNido/security/advisories/new
about: Do NOT open a public issue. Report privately through GitHub's security advisories — see SECURITY.md.
- name: 💬 Question or discussion
url: https://github.com/pablitofernandez/FamilyNido/discussions
about: For open-ended questions, setup help and ideas, prefer Discussions over Issues.

View file

@ -0,0 +1,47 @@
name: ✨ Feature request
description: Propose a new feature or an improvement to an existing one.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for thinking about FamilyNido's roadmap. Before filling this in,
please read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) — the
project is scoped to **a single household per instance**, and features
that primarily serve someone else's customers (multi-tenant SaaS,
commercial hosting, white-labelling) are out of scope.
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: Describe the situation in your family that this would help with. The *why* matters much more than the *what*.
placeholder: Every morning we forget who is picking up the kids from school because…
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution (optional)
description: If you already have an idea of how this could work, jot it here. Otherwise leave blank — the design discussion can happen on the issue.
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Existing workarounds you've tried (in FamilyNido or other tools) and why they fall short.
validations:
required: false
- type: checkboxes
id: scope
attributes:
label: Scope check
options:
- label: This is useful for a typical family running its own instance, not just my edge case.
required: true
- label: This does not require multi-tenant features, billing, white-labelling or hosting it as a service for other families.
required: true

37
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,37 @@
# Dependabot keeps each ecosystem's dependencies fresh on its own cadence.
# Weekly batches keep the PR firehose manageable for a one-maintainer repo.
version: 2
updates:
- package-ecosystem: "nuget"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "backend"
- package-ecosystem: "npm"
directory: "/web/familynido-web"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "frontend"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "ci"
- package-ecosystem: "docker"
directory: "/deploy"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "docker"

25
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,25 @@
<!--
Thanks for opening a PR! Quick checklist before submitting:
- Non-trivial changes should have a linked issue discussing scope first.
- Match the existing code style (.editorconfig + Angular CLI schematics).
- API changes need at least one integration test under tests/FamilyNido.Tests/.
- UI changes should be verified in a browser before submitting.
- Keep the change focused on one concern.
-->
## Summary
<!-- 1-2 sentences describing what changes and why. -->
## Linked issue
<!-- e.g. Closes #42, or "none — trivial fix". -->
## How was this tested?
<!-- "Ran dotnet test", "Played with the new flow in a browser using the demo seed", "Added X integration test", … -->
## Screenshots / clips (UI changes only)
<!-- Drop one or two before/after captures. Scrub real family data. -->

64
.github/workflows/build-and-push.yml vendored Normal file
View file

@ -0,0 +1,64 @@
name: Build and publish images
on:
push:
branches: [main]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAMESPACE: ${{ github.repository_owner }}
# Run JS-based actions on Node 24 — Node 20 is deprecated on GH-hosted
# runners (forced switch 2026-06-02, removal 2026-09-16). Opt in early
# so the warning goes away and we don't get caught flat-footed.
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
image:
- name: familynido-api
dockerfile: deploy/api.Dockerfile
- name: familynido-web
dockerfile: deploy/web.Dockerfile
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image.name }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
- name: Build and push ${{ matrix.image.name }}
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.image.dockerfile }}
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.image.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.image.name }}

300
.github/workflows/e2e.yml vendored Normal file
View file

@ -0,0 +1,300 @@
name: E2E (Playwright)
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
# Run JS-based actions (checkout, setup-dotnet, setup-node, etc.) on Node 24
# instead of the deprecated Node 20 runtime. Node 20 is being removed from
# GitHub-hosted runners on 2026-09-16; this opt-in silences the warning and
# avoids the forced switch on 2026-06-02.
# Ref: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
jobs:
playwright:
runs-on: ubuntu-latest
timeout-minutes: 25
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: familynido
POSTGRES_USER: familynido
POSTGRES_PASSWORD: familynido
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U familynido -d familynido"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
# The seeded testers' credentials. Stable across runs because the
# E2ETestDataSeeder upserts on email — it never rotates an existing
# password — and these values are only ever real on this ephemeral
# Postgres instance, never in production.
E2E_USER_EMAIL: e2e-a@familynido.test
E2E_USER_PASSWORD: SuperSecret123!
E2E_USER_B_EMAIL: e2e-b@familynido.test
E2E_USER_B_PASSWORD: SuperSecret123!
E2E_USER_B_NAME: Tester B
E2E_BASE_URL: http://localhost:5173
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/familynido-web/package-lock.json
- name: Restore .NET
run: dotnet restore
- name: Build & publish API
run: |
dotnet publish src/FamilyNido.Api -c Release -o publish/api --no-restore
ls -la publish/api/FamilyNido.Api.dll
- name: Install SPA deps
working-directory: web/familynido-web
run: npm ci
- name: Build SPA (es + en bundles)
working-directory: web/familynido-web
run: npm run build
- name: Install nginx
run: sudo apt-get update -y && sudo apt-get install -y nginx
- name: Configure nginx (proxy /api → :8080, serve SPA at :5173)
run: |
set -euxo pipefail
BUILT="$GITHUB_WORKSPACE/web/familynido-web/dist/familynido-web/browser"
if [ ! -d "$BUILT/es" ] || [ ! -d "$BUILT/en" ]; then
echo "Localized SPA bundles not found at $BUILT" >&2
ls -la "$BUILT" >&2 || true
exit 1
fi
# Copy the SPA out of /home/runner/... — nginx runs as www-data
# and can't traverse into the runner's home tree by default. /var/www
# is the canonical "nginx-readable" location.
SPA_ROOT=/var/www/familynido-e2e
sudo rm -rf "$SPA_ROOT"
sudo mkdir -p "$SPA_ROOT"
sudo cp -r "$BUILT"/. "$SPA_ROOT/"
sudo chown -R www-data:www-data "$SPA_ROOT"
sudo find "$SPA_ROOT" -type d -exec chmod 755 {} \;
sudo find "$SPA_ROOT" -type f -exec chmod 644 {} \;
echo "── /var/www/familynido-e2e tree ──"
ls -la "$SPA_ROOT"
ls -la "$SPA_ROOT/es" | head -n 5
sudo tee /etc/nginx/sites-available/familynido-e2e > /dev/null <<EOF
server {
listen 5173 default_server;
server_name _;
root $SPA_ROOT;
index index.html;
absolute_redirect off;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 60s;
}
location = / {
if (\$http_accept_language ~* "^en") { return 302 /en/; }
return 302 /es/;
}
location /es/ { try_files \$uri /es/index.html; }
location /en/ { try_files \$uri /en/index.html; }
# Anything else (e.g. /tasks, /calendar) is a Playwright spec
# navigating without the locale prefix. Redirect through to
# /es/<path> so the SPA loads with the right base href instead
# of nginx returning its default 404.
location / {
return 302 /es\$request_uri;
}
}
EOF
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/familynido-e2e /etc/nginx/sites-enabled/familynido-e2e
sudo nginx -t
- name: Start API + wait for ready (combined step)
env:
ASPNETCORE_ENVIRONMENT: Testing
# Bind to all interfaces so curl on 127.0.0.1 reaches Kestrel
# regardless of how the runner resolves localhost.
ASPNETCORE_URLS: http://+:8080
# Disable Serilog buffering — write to console with line-buffered
# output so we see startup messages in real time.
Serilog__MinimumLevel: Information
DOTNET_PRINT_TELEMETRY_MESSAGE: 'false'
DOTNET_NOLOGO: '1'
ConnectionStrings__Postgres: Host=localhost;Port=5432;Database=familynido;Username=familynido;Password=familynido
Family__AutoMigrate: 'true'
Seed__E2E__Enabled: 'true'
# Pin the seeded family's timezone to UTC so the date the browser
# computes via `new Date()` (UTC in CI) and the date the server
# resolves via `family.TimeZone` always agree. Otherwise a CI run
# crossing midnight UTC pushes a task into a day the server's
# "today" doesn't include yet, and `/api/household-tasks/today`
# returns an empty list.
Seed__E2E__TimeZone: 'UTC'
Seed__E2E__UserAEmail: ${{ env.E2E_USER_EMAIL }}
Seed__E2E__UserAPassword: ${{ env.E2E_USER_PASSWORD }}
Seed__E2E__UserBEmail: ${{ env.E2E_USER_B_EMAIL }}
Seed__E2E__UserBPassword: ${{ env.E2E_USER_B_PASSWORD }}
Seed__E2E__UserBDisplayName: ${{ env.E2E_USER_B_NAME }}
Email__Enabled: 'false'
Email__AppBaseUrl: http://localhost:5173
# Calendar sync is dormant in CI — the loop kicks off but never
# acts because no Google account is linked.
Calendar__SyncIntervalMinutes: '999999'
run: |
set -uxo pipefail
mkdir -p logs
echo "── dotnet --info ──"
dotnet --info | head -n 30
echo "── DLL stat ──"
ls -la "$GITHUB_WORKSPACE/publish/api/FamilyNido.Api.dll"
# `stdbuf -o0 -e0` forces unbuffered stdio so any framework or
# Serilog writes appear in api.log immediately, not after a 4 KB
# block fills. cd into the publish dir so config files next to
# the DLL (appsettings.json, etc.) are found.
cd "$GITHUB_WORKSPACE/publish/api"
stdbuf -o0 -e0 dotnet ./FamilyNido.Api.dll \
> "$GITHUB_WORKSPACE/logs/api.log" 2>&1 &
API_PID=$!
echo "$API_PID" > "$GITHUB_WORKSPACE/logs/api.pid"
cd "$GITHUB_WORKSPACE"
echo "API started with PID $API_PID"
# 5 s grace period for first writes / JIT warm-up.
sleep 5
echo "── after 5 s, api.log ──"
cat logs/api.log || true
echo "── ports listening ──"
ss -tlnp 2>/dev/null | head -n 30 || true
# Wait loop with periodic snapshots.
for i in $(seq 1 90); do
if curl -fsS http://127.0.0.1:8080/health/ready > /dev/null; then
echo "API ready after ${i}s"
exit 0
fi
if ! kill -0 "$API_PID" 2>/dev/null; then
echo "API process exited; dumping log:" >&2
cat logs/api.log >&2 || true
exit 1
fi
if [ $((i % 10)) -eq 0 ]; then
echo "── snapshot @ ${i}s ──" >&2
ss -tlnp 2>/dev/null | grep -E ':(8080|5432) ' >&2 || true
echo "── api.log tail ──" >&2
tail -n 40 logs/api.log >&2 || true
fi
sleep 1
done
echo "API never became ready" >&2
echo "── Final full api.log ──" >&2
cat logs/api.log >&2 || true
exit 1
- name: Start nginx
run: sudo systemctl restart nginx
- name: Wait for SPA root
run: |
set -uxo pipefail
for i in $(seq 1 30); do
if curl -fsS -o /dev/null -L http://127.0.0.1:5173/es/; then
echo "SPA ready after ${i}s"
exit 0
fi
sleep 1
done
echo "SPA never became reachable. Diagnostics:" >&2
echo "── curl -i (verbose response) ──" >&2
curl -i http://127.0.0.1:5173/es/ 2>&1 | head -n 30 >&2 || true
echo "── nginx error.log ──" >&2
sudo tail -n 50 /var/log/nginx/error.log >&2 || true
echo "── nginx access.log ──" >&2
sudo tail -n 20 /var/log/nginx/access.log >&2 || true
echo "── nginx process ──" >&2
ps aux | grep nginx | grep -v grep >&2 || true
exit 1
- name: Install Playwright browsers
working-directory: web/familynido-web
run: npx playwright install --with-deps chromium
- name: Run Playwright
working-directory: web/familynido-web
run: npx playwright test
- name: Stop API
if: always()
run: |
if [ -f logs/api.pid ]; then
kill "$(cat logs/api.pid)" || true
fi
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: web/familynido-web/playwright-report
retention-days: 14
- name: Upload Playwright traces on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: web/familynido-web/test-results
retention-days: 14
- name: Upload API log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: api-log
path: logs/api.log
retention-days: 14

73
.gitignore vendored Normal file
View file

@ -0,0 +1,73 @@
# ─── .NET ─────────────────────────────────────────────
bin/
obj/
*.user
*.suo
*.userprefs
.vs/
.vscode/
.idea/
*.lock.json
project.lock.json
artifacts/
coverage/
*.coverage
*.trx
TestResults/
# ─── Angular / Node ───────────────────────────────────
node_modules/
dist/
.angular/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.cache/
.parcel-cache/
.turbo/
# ─── Environment & secrets ────────────────────────────
.env
.env.local
.env.*.local
*.pfx
*.snk
# ─── Local tool state ─────────────────────────────────
# Claude Code per-repo permissions and preferences.
.claude/
# ─── OS ───────────────────────────────────────────────
Thumbs.db
Desktop.ini
.DS_Store
ehthumbs.db
$RECYCLE.BIN/
# ─── Editor ───────────────────────────────────────────
*.swp
*~
# ─── Docker ───────────────────────────────────────────
.docker-data/
# ─── EF Core artifacts ────────────────────────────────
*.db
*.db-shm
*.db-wal
# ─── Temporary / local scratch ────────────────────────
scratch/
tmp/
# ─── Local uploads (wall images, future attachments) ──
# Created by the Api when running in dev; production uses the
# `familynido-files` Docker volume and never hits this path.
src/FamilyNido.Api/data/
# ─── Personal design / spec notes kept out of the public repo ─────
FamilyNido.md
design/

169
CLAUDE.md Normal file
View file

@ -0,0 +1,169 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# C# Development SECTION START
---
description: 'Guidelines for building C# applications'
applyTo: '**/*.cs'
---
## C# Instructions
- Always use the latest version C#, currently C# 14 features.
- Write clear and concise comments for each function.
## General Instructions
- Make only high confidence suggestions when reviewing code changes.
- Write code with good maintainability practices, including comments on why certain design decisions were made.
- Handle edge cases and write clear exception handling.
- For libraries or external dependencies, mention their usage and purpose in comments.
## Naming Conventions
- Follow PascalCase for component names, method names, and public members.
- Use camelCase for private fields and local variables.
- Prefix interface names with "I" (e.g., IUserService).
## Formatting
- Apply code-formatting style defined in `.editorconfig`.
- Prefer file-scoped namespace declarations and single-line using directives.
- Insert a newline before the opening curly brace of any code block (e.g., after `if`, `for`, `while`, `foreach`, `using`, `try`, etc.).
- Ensure that the final return statement of a method is on its own line.
- Use pattern matching and switch expressions wherever possible.
- Use `nameof` instead of string literals when referring to member names.
- Ensure that XML doc comments are created for any public APIs. When applicable, include `<example>` and `<code>` documentation in the comments.
## Project Setup and Structure
- Guide users through creating a new .NET project with the appropriate templates.
- Explain the purpose of each generated file and folder to build understanding of the project structure.
- Demonstrate how to organize code using feature folders or domain-driven design principles.
- Show proper separation of concerns with models, services, and data access layers.
- Explain the Program.cs and configuration system in ASP.NET Core 10 including environment-specific settings.
## Nullable Reference Types
- Declare variables non-nullable, and check for `null` at entry points.
- Always use `is null` or `is not null` instead of `== null` or `!= null`.
- Trust the C# null annotations and don't add null checks when the type system says a value cannot be null.
# C# Development SECTION END
# Angular Development Instructions SECTION START
description: 'Angular-specific coding standards and best practices'
applyTo: '**/*.ts, **/*.html, **/*.scss, **/*.css'
---
Instructions for generating high-quality Angular applications with TypeScript, using Angular Signals for state management, adhering to Angular best practices as outlined at https://angular.dev.
## Project Context
- Latest Angular version (use standalone components by default)
- TypeScript for type safety
- Angular CLI for project setup and scaffolding
- Follow Angular Style Guide (https://angular.dev/style-guide)
## Development Standards
### Architecture
- Use standalone components unless modules are explicitly required
- Organize code by standalone feature modules or domains for scalability
- Implement lazy loading for feature modules to optimize performance
- Use Angular's built-in dependency injection system effectively
- Structure components with a clear separation of concerns (smart vs. presentational components)
### TypeScript
- Enable strict mode in `tsconfig.json` for type safety
- Define clear interfaces and types for components, services, and models
- Use type guards and union types for robust type checking
- Implement proper error handling with RxJS operators (e.g., `catchError`)
- Use typed forms (e.g., `FormGroup`, `FormControl`) for reactive forms
### Component Design
- Follow Angular's component lifecycle hooks best practices
- When using Angular >= 19, Use `input()` `output()`, `viewChild()`, `viewChildren()`, `contentChild()` and `contentChildren()` functions instead of decorators; otherwise use decorators
- Leverage Angular's change detection strategy (default or `OnPush` for performance)
- Keep templates clean and logic in component classes or services
- Use Angular directives and pipes for reusable functionality
### Styling
- Use Angular's component-level CSS encapsulation (default: ViewEncapsulation.Emulated)
- Prefer SCSS for styling with consistent theming
- Implement responsive design using CSS Grid, Flexbox, or Angular CDK Layout utilities
- Follow Angular Material's theming guidelines if used
- Maintain accessibility (a11y) with ARIA attributes and semantic HTML
### State Management
- Use Angular Signals for reactive state management in components and services
- Leverage `signal()`, `computed()`, and `effect()` for reactive state updates
- Use writable signals for mutable state and computed signals for derived state
- Handle loading and error states with signals and proper UI feedback
- Use Angular's `AsyncPipe` to handle observables in templates when combining signals with RxJS
### Data Fetching
- Use Angular's `HttpClient` for API calls with proper typing
- Implement RxJS operators for data transformation and error handling
- Use Angular's `inject()` function for dependency injection in standalone components
- Implement caching strategies (e.g., `shareReplay` for observables)
- Store API response data in signals for reactive updates
- Handle API errors with global interceptors for consistent error handling
### Security
- Sanitize user inputs using Angular's built-in sanitization
- Implement route guards for authentication and authorization
- Use Angular's `HttpInterceptor` for CSRF protection and API authentication headers
- Validate form inputs with Angular's reactive forms and custom validators
- Follow Angular's security best practices (e.g., avoid direct DOM manipulation)
### Performance
- Enable production builds with `ng build --prod` for optimization
- Use lazy loading for routes to reduce initial bundle size
- Optimize change detection with `OnPush` strategy and signals for fine-grained reactivity
- Use trackBy in `ngFor` loops to improve rendering performance
- Implement server-side rendering (SSR) or static site generation (SSG) with Angular Universal (if specified)
### Testing
- Write unit tests for components, services, and pipes using Jasmine and Karma
- Use Angular's `TestBed` for component testing with mocked dependencies
- Test signal-based state updates using Angular's testing utilities
- Write end-to-end tests with Cypress or Playwright (if specified)
- Mock HTTP requests using `provideHttpClientTesting`
- Ensure high test coverage for critical functionality
## Additional Guidelines
- **File naming (classic Angular convention, project-wide)**: each Angular `@Component` lives in its own folder with **three separated files**`feature.component.ts` (logic), `feature.component.html` (template) and `feature.component.css` (component-scoped styles, created even if empty so the structure is uniform). The exported class must carry the `Component` suffix, e.g. `export class FeatureComponent { … }`. Services keep the `feature.service.ts` convention, guards `feature.guard.ts`, interceptors `feature.interceptor.ts`, models no suffix. The Angular CLI schematics in `angular.json` are already configured to produce this layout — always generate new components with `ng generate component <name>`.
- Use Angular CLI commands for generating boilerplate code
- Document components and services with clear JSDoc comments
- Ensure accessibility compliance (WCAG 2.1) where applicable
- Use Angular's built-in i18n for internationalization (if specified)
- Keep code DRY by creating reusable utilities and shared modules
- Use signals consistently for state management to ensure reactive updates
# Angular Development Instructions SECTION END
### Code Intelligence
Prefer LSP over Grep/Glob/Read for code navigation:
- `goToDefinition` / `goToImplementation` to jump to source
- `findReferences` to see all usages across the codebase
- `workspaceSymbol` to find where something is defined
- `documentSymbol` to list all symbols in a file
- `hover` for type info without reading the file
- `incomingCalls` / `outgoingCalls` for call hierarchy
Before renaming or changing a function signature, use
`findReferences` to find all call sites first.
Use Grep/Glob only for text/pattern searches (comments,
strings, config values) where LSP doesn't help.
After writing or editing code, check LSP diagnostics before
moving on. Fix any type errors or missing imports immediately.
.NET Instructions
Use a Vertical Slice Architecture.

26
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,26 @@
# Contributing to FamilyNido
FamilyNido is a personal side project maintained in evenings and weekends.
It is scoped on purpose to be a self-hosted PWA for **a single household per
instance** — multi-tenant SaaS, commercial hosting, white-labelling and
anything whose primary value is serving someone else's customers is **out of
scope**. Not because those things are bad, but because supporting them would
break the simplicity that makes this project maintainable for one person.
The MIT license is exactly the right tool if you want to fork and go that way.
Bug reports and small, focused PRs are welcome. For non-trivial changes
please open an issue first to align on scope before you write code — I would
rather say *"this won't be merged because X"* before you spend a weekend on it
than after. PR review cadence is best-effort: this is a hobby project, so
family and work come first; expect days, sometimes weeks. If you touch the
API please add at least one integration test under `tests/FamilyNido.Tests/`;
if you touch the UI please verify the change in a browser before submitting
(`docker compose -f deploy/docker-compose.dev.yml up -d`, `dotnet watch run`
in `src/FamilyNido.Api`, `npm start` in `web/familynido-web`). Match the
existing style — the `.editorconfig` and the Angular CLI schematics already
encode it.
For security problems do **not** open a public issue. Use the *Report a
vulnerability* button on the GitHub **Security** tab — the full policy lives
in [`SECURITY.md`](./SECURITY.md). For everything else, be kind, assume good
intent and keep the discussion technical.

16
Directory.Build.props Normal file
View file

@ -0,0 +1,16 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors />
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<AnalysisLevel>latest</AnalysisLevel>
<Deterministic>true</Deterministic>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
</Project>

10
FamilyNido.slnx Normal file
View file

@ -0,0 +1,10 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/FamilyNido.Api/FamilyNido.Api.csproj" />
<Project Path="src/FamilyNido.Domain/FamilyNido.Domain.csproj" />
<Project Path="src/FamilyNido.Persistence/FamilyNido.Persistence.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/FamilyNido.Tests/FamilyNido.Tests.csproj" />
</Folder>
</Solution>

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Pablo Fernández
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

350
README.md Normal file
View file

@ -0,0 +1,350 @@
# 🏡 FamilyNido
> The digital nest for a single household — calendars, chores, meals, school,
> health and family conversations, all in one place. Self-hosted, ad-free,
> telemetry-free, one instance per family.
Built as a PWA you install on every phone, tablet and laptop in the house.
Designed to be deployed in ten minutes on any home server with `docker compose`.
> 🤖 **Pair-programmed end-to-end with [Claude Code](https://claude.com/claude-code)
> (Opus 4.7).** This repository is an experiment in how far a single developer
> can go shipping a real product side-by-side with an AI coding partner —
> architecture, vertical slices, tests, infra, docs and this very README.
![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 MonThu 918"*, *"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.
<details>
<summary><strong>End-to-end tests (Playwright)</strong> — click to expand</summary>
Specs live in `web/familynido-web/e2e/` and drive a full stack (API + Postgres
+ nginx). To run them locally:
1. **Seed the two test users.** The API ships an `E2ETestDataSeeder` that only
kicks in when the environment is `Testing` and the flag `Seed:E2E:Enabled`
is set. Start the API like this:
```bash
cd src/FamilyNido.Api
ASPNETCORE_ENVIRONMENT=Testing \
Seed__E2E__Enabled=true \
Seed__E2E__UserAPassword=SuperSecret123! \
Seed__E2E__UserBPassword=SuperSecret123! \
dotnet run
```
This creates the `FamilyNido E2E` family with tester A (admin) and tester B
idempotently. The seed lives in its own family row so it never collides
with real data.
2. **Serve the SPA behind nginx** — the specs target port `:5173` by default
because `ng serve` doesn't understand the `/es` and `/en` subpaths produced
by the i18n build.
3. **Run Playwright**:
```bash
cd web/familynido-web
E2E_BASE_URL=http://localhost:5173 \
E2E_USER_EMAIL=e2e-a@familynido.test \
E2E_USER_PASSWORD=SuperSecret123! \
E2E_USER_B_EMAIL=e2e-b@familynido.test \
E2E_USER_B_PASSWORD=SuperSecret123! \
npm run e2e
```
`npm run e2e:ui` opens Playwright's time-travel UI for debugging.
In CI, `.github/workflows/e2e.yml` boots the full stack automatically and
uploads traces as an artifact whenever a spec fails.
</details>
---
## 🚢 Deployment
```bash
cp deploy/.env.example deploy/.env
# Edit deploy/.env with real values (OIDC, Postgres, VAPID, SMTP, …)
docker compose -f deploy/docker-compose.prod.yml up -d
```
The containers (`familynido-api`, `familynido-web`, `familynido-db`) ship with
Traefik labels so an external Traefik instance can expose them through your
configured `TRAEFIK_HOST`. Refer to [`deploy/README.md`](./deploy/README.md)
for first-time server setup, Google Calendar OAuth wiring and rollback
procedure.
---
## 🔒 Security
Reports of vulnerabilities go through GitHub's *Report a vulnerability* button
on the **Security** tab — never as a public issue. Full policy and threat
model in [`SECURITY.md`](./SECURITY.md).
---
## 📄 License
MIT — see [`LICENSE`](./LICENSE).

50
SECURITY.md Normal file
View file

@ -0,0 +1,50 @@
# Security policy
## Supported versions
FamilyNido is a single-family self-hosted PWA. Only the `main` branch is
supported — there are no released "versions" to back-port fixes to.
## Reporting a vulnerability
If you find something that looks like a security issue, please **do not**
open a public GitHub issue. Instead:
1. Use GitHub's *Report a vulnerability* button on the **Security** tab of
this repository (private disclosure to maintainers).
2. Or email the maintainer directly via the address on the GitHub profile
linked from the commit history.
Please include enough detail to reproduce: affected endpoint(s), inputs,
expected vs. actual behaviour, and any logs you can share. A short
proof-of-concept goes a long way.
You can expect:
- An acknowledgement within a few days.
- A fix or a clear "won't fix with reason" within two weeks for
high-severity issues, longer for low-severity ones.
- A public credit in the commit message if you want one.
## Out of scope
- DDoS, volumetric or resource-exhaustion attacks against the
reference deployment. Rate limiting is in place but the project is not
designed to survive large-scale attacks.
- Issues that require physical access to the server hosting the instance.
- Vulnerabilities in upstream dependencies that already have a published
CVE — open a regular issue (or PR with the bump) instead.
- Social-engineering or anything that targets an operator rather than the
software itself.
## Threat model in one paragraph
FamilyNido is intended to run on a home server behind a reverse proxy that
terminates TLS (typically Traefik), accessible to the household members
through their own browsers and to a handful of integrations (Home
Assistant, etc.) through API keys. The realistic attackers we worry about
are: a curious neighbour on the LAN, a guest the family briefly granted
access to, and the open internet poking at the public URL once the
service is exposed. Strong assumptions: the operator controls the host,
keeps the OS patched, and runs the `prod.yml` stack instead of editing
containers by hand.

55
deploy/.env.example Normal file
View file

@ -0,0 +1,55 @@
# ─── General ────────────────────────────────────────────────────────────────
FAMILYNIDO_BASE_URL=https://familia.example.com
FAMILYNIDO_TIMEZONE=Europe/Madrid
# UID/GID under which the API container will write files. Leave empty to
# keep the default in-container user (UID 100, useful with Docker named
# volumes). Set to a specific user (typically 1000:1000) when you bind-mount
# a host directory and want files to remain owned by that host user — e.g.
# /home/dan/familynido/files mapped to /app/data/files.
PUID=
PGID=
# ─── GHCR (only consumed by docker-compose.prod.yml) ────────────────────────
# GitHub user or org that owns the published images under
# ghcr.io/<GHCR_OWNER>/familynido-api and /familynido-web.
GHCR_OWNER=your-github-user
# ─── PostgreSQL ─────────────────────────────────────────────────────────────
POSTGRES_DB=familynido
POSTGRES_USER=familynido
POSTGRES_PASSWORD=change-me-please
# ─── OIDC (PocketID in prod) ────────────────────────────────────────────────
OIDC_AUTHORITY=https://pocketid.example.com
OIDC_CLIENT_ID=familynido
OIDC_CLIENT_SECRET=
OIDC_REQUIRE_HTTPS=true
# ─── Google Calendar (read-only sync) ───────────────────────────────────────
# Credentials issued by https://console.cloud.google.com/apis/credentials with
# the Calendar API enabled. The redirect URI must match exactly the entry
# registered as an "Authorized redirect URI" for the OAuth client.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URI=https://familia.example.com/api/calendar/google/callback
# ─── Email (SMTP) — used by the invitation flow ─────────────────────────────
# Generic SMTP relay. Works with Brevo (formerly Sendinblue), Gmail with an
# app password, mailcow, an internal MTA, etc. Leave EMAIL__ENABLED=false to
# disable email entirely; admins will still see the "copy link" fallback.
EMAIL__ENABLED=false
EMAIL__SMTPHOST=smtp-relay.example.com
EMAIL__SMTPPORT=587
EMAIL__SMTPUSERNAME=
EMAIL__SMTPPASSWORD=
EMAIL__SMTPUSESTARTTLS=true
EMAIL__FROM=FamilyNido <noreply@familia.example.com>
EMAIL__APPBASEURL=https://familia.example.com
EMAIL__INVITATIONLIFETIME=7.00:00:00
# ─── Traefik (optional — consumed by labels in docker-compose.prod.yml) ────
TRAEFIK_NETWORK=traefik
TRAEFIK_ROUTER_NAME=familynido
TRAEFIK_HOST=familia.example.com
TRAEFIK_TLS_RESOLVER=letsencrypt

117
deploy/README.md Normal file
View file

@ -0,0 +1,117 @@
# Deployment
Two compose files live here, for two distinct scenarios:
| File | Purpose |
|---|---|
| `docker-compose.prod.yml` | Pulls pre-built images from GHCR. This is what runs on the home server. |
| `docker-compose.dev.yml` | Dev stack with Postgres only — auth runs on local credentials. |
Images are built by `.github/workflows/build-and-push.yml` on every push to
`main` and published to:
- `ghcr.io/<GHCR_OWNER>/familynido-api:latest`
- `ghcr.io/<GHCR_OWNER>/familynido-web:latest`
Each push also produces an immutable `sha-<shortsha>` tag for rollbacks.
## First-time server setup
Prerequisites on the server (Ubuntu, Docker, Traefik on the
`${TRAEFIK_NETWORK}` network with a Let's Encrypt cert resolver).
1. **Create a Personal Access Token** on GitHub with scope `read:packages`.
GHCR is private for private repos, so the server needs to authenticate.
2. **Log in to GHCR on the server** (one-time, the credentials are cached
in `~/.docker/config.json`):
```bash
echo <PAT> | docker login ghcr.io -u <github-username> --password-stdin
```
3. **Prepare the deploy folder** (e.g. `/opt/familynido/`):
```bash
sudo mkdir -p /opt/familynido
sudo chown $USER:$USER /opt/familynido
cd /opt/familynido
```
Copy `deploy/docker-compose.prod.yml` and `deploy/.env.example` from
this repo into that folder, rename the example to `.env`, and fill it in
with the real PocketId/Postgres credentials.
4. **Pull and start**:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
5. Traefik picks the service up via the labels and serves it at the
`${TRAEFIK_HOST}` you configured once Let's Encrypt issues the cert.
> **OIDC client id.** The default `OIDC_CLIENT_ID` is `familynido`. If you
> use PocketID or another upstream IdP, create a client with that id (or
> change the value here to match what your IdP exposes).
## Google Calendar credentials
The calendar module mirrors events from Google Calendar (read-only). The
backend needs an OAuth client to drive the consent flow:
1. Create a project at https://console.cloud.google.com and enable the
**Google Calendar API**.
2. In **APIs & Services → OAuth consent screen**, configure an external app
and add the scope `https://www.googleapis.com/auth/calendar.readonly`.
Point the privacy/terms URLs at the pages your deployment serves under
`/legal/privacidad.html` and `/legal/condiciones.html`. Both live in
`web/familynido-web/public/legal/` in the repo and are deployed
automatically with the web image.
3. In **APIs & Services → Credentials**, create an **OAuth 2.0 Client ID** of
type *Web application*. Set the authorized redirect URI to exactly
`https://<your-host>/api/calendar/google/callback`.
4. Copy the client id and secret into `.env`:
```env
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_OAUTH_REDIRECT_URI=https://<your-host>/api/calendar/google/callback
```
5. Restart the API container so the new env vars are picked up:
```bash
docker compose -f docker-compose.prod.yml up -d --force-recreate api
```
The callback path is already proxied by the existing `/api/` block in
`deploy/nginx/default.conf`, so no extra reverse-proxy config is needed.
## Updating after a push to `main`
Until Watchtower is wired up (separate commit), updates are a manual pull:
```bash
cd /opt/familynido
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
## Rolling back
Each build tags the images with `sha-<shortsha>`. To roll back to a previous
build without reverting the main branch:
```bash
# 1. Pin the images to a known-good SHA in docker-compose.prod.yml,
# e.g. ghcr.io/.../familynido-api:sha-a57d52e
# 2. docker compose -f docker-compose.prod.yml up -d
```
## Database migrations
Not yet automated. After a deploy that includes a new EF migration, run
them manually from the repo on the dev machine pointed at the prod DB, or
(TODO) add `db.Database.Migrate()` to `Program.cs` gated by config.

46
deploy/api-entrypoint.sh Normal file
View file

@ -0,0 +1,46 @@
#!/bin/sh
# Entrypoint for the FamilyNido API container.
#
# Runs briefly as root, normalizes ownership of the writable mount points
# and drops privileges via `su-exec`. Two scenarios are supported:
#
# 1. Docker named volume (default in our docker-compose.prod.yml). The
# mount point is created as root on first start; we chown it to the
# `familynido` user that the image ships with (UID 100, GID 101 on
# Alpine).
#
# 2. Host bind mount (e.g. `/home/dan/.../files:/app/data/files`). The
# operator likely wants the files to remain owned by their host user
# so they can browse/back them up without sudo. To support that, set
# PUID/PGID env vars to the host user's IDs (commonly 1000:1000); the
# script recreates the in-container `familynido` user with those IDs
# before chowning.
set -e
CURRENT_UID=$(id -u familynido)
CURRENT_GID=$(id -g familynido)
TARGET_UID="${PUID:-$CURRENT_UID}"
TARGET_GID="${PGID:-$CURRENT_GID}"
if [ "$TARGET_UID" != "$CURRENT_UID" ] || [ "$TARGET_GID" != "$CURRENT_GID" ]; then
# Drop the existing user/group and recreate them with the requested IDs.
deluser familynido 2>/dev/null || true
delgroup familynido 2>/dev/null || true
addgroup -g "$TARGET_GID" familynido
adduser -u "$TARGET_UID" -G familynido -h /home/familynido -D familynido
# Re-stamp the published .NET binaries and the home dir so the new
# familynido (different UID/GID) can read everything.
chown -R familynido:familynido /app /home/familynido
fi
# Idempotent ownership normalization for the writable mount points. Works
# for both named volumes (start out root:root) and bind mounts (whatever
# the host owner is).
for dir in /app/data/files /home/familynido/.aspnet/DataProtection-Keys; do
if [ -d "$dir" ]; then
chown -R familynido:familynido "$dir"
fi
done
exec su-exec familynido:familynido "$@"

64
deploy/api.Dockerfile Normal file
View file

@ -0,0 +1,64 @@
# syntax=docker/dockerfile:1.7
# FamilyNido backend — .NET 10 ASP.NET Core Minimal API.
# Multi-stage: restore+publish on the SDK, run on the ASP.NET runtime.
ARG DOTNET_VERSION=10.0
# ─── Build stage ────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-alpine AS build
WORKDIR /src
# Copy just the csproj/solution files first to cache the restore layer.
COPY Directory.Build.props global.json ./
COPY src/FamilyNido.Api/FamilyNido.Api.csproj src/FamilyNido.Api/
COPY src/FamilyNido.Domain/FamilyNido.Domain.csproj src/FamilyNido.Domain/
COPY src/FamilyNido.Persistence/FamilyNido.Persistence.csproj src/FamilyNido.Persistence/
COPY tests/FamilyNido.Tests/FamilyNido.Tests.csproj tests/FamilyNido.Tests/
RUN dotnet restore src/FamilyNido.Api/FamilyNido.Api.csproj
# Now copy the sources and publish a self-contained trim-free release.
COPY . .
RUN dotnet publish src/FamilyNido.Api/FamilyNido.Api.csproj \
--configuration Release \
--no-restore \
--output /app/publish \
/p:UseAppHost=false
# ─── Runtime stage ──────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-alpine AS runtime
WORKDIR /app
# Non-root user to follow least-privilege. Pre-create the writable
# directories with the right owner so that a Docker volume mounted on them
# at runtime inherits the permissions on first creation.
RUN addgroup -S familynido && adduser -S -G familynido familynido \
&& mkdir -p /app/data/files /home/familynido/.aspnet/DataProtection-Keys \
&& chown -R familynido:familynido /app /home/familynido
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_RUNNING_IN_CONTAINER=true \
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
# ICU + tzdata so DateTimeOffset.ToLocalTime() and es-ES formatting work.
# `su-exec` lets the entrypoint drop privileges after the chown step —
# alpine ships it as a tiny ~10 KB binary equivalent to gosu.
RUN apk add --no-cache icu-libs icu-data-full tzdata su-exec
COPY --from=build --chown=familynido:familynido /app/publish .
COPY --chown=root:root deploy/api-entrypoint.sh /usr/local/bin/api-entrypoint.sh
# sed strips any CRLF line endings introduced by Windows checkouts so the
# shebang and statements parse correctly on the Alpine /bin/sh.
RUN sed -i 's/\r$//' /usr/local/bin/api-entrypoint.sh \
&& chmod +x /usr/local/bin/api-entrypoint.sh
# Note: do NOT set USER familynido here. The entrypoint starts as root,
# normalizes ownership of the volume mount points, and then exec-s into the
# .NET host as `familynido` via su-exec.
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:8080/health/live || exit 1
ENTRYPOINT ["/usr/local/bin/api-entrypoint.sh"]
CMD ["dotnet", "FamilyNido.Api.dll"]

View file

@ -0,0 +1,31 @@
# FamilyNido — development compose stack.
# Brings up PostgreSQL so you can develop the Angular app (ng serve on :4200)
# and the .NET backend (dotnet watch on :5080) against a real database.
#
# Auth in dev is handled by local credentials only (POST /api/auth/local/login)
# — the OIDC code path is for production with PocketID and is hidden in the
# login UI when /api/auth/providers reports oidcEnabled=false.
#
# docker compose -f deploy/docker-compose.dev.yml up -d
services:
db:
image: postgres:16-alpine
container_name: familynido-db-dev
restart: unless-stopped
environment:
POSTGRES_DB: familynido
POSTGRES_USER: familynido
POSTGRES_PASSWORD: familynido
ports:
- "5435:5432"
volumes:
- familynido-db-dev-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U familynido -d familynido"]
interval: 10s
timeout: 5s
retries: 5
volumes:
familynido-db-dev-data:

View file

@ -0,0 +1,108 @@
# FamilyNido — production compose stack for the home Ubuntu server.
# Pulls pre-built images from GHCR (published by .github/workflows/build-and-push.yml)
# instead of building on the host. Run from /opt/familynido/ (or wherever this
# file lives alongside its .env) with:
#
# docker compose -f docker-compose.prod.yml pull
# docker compose -f docker-compose.prod.yml up -d
#
# Assumes an external Traefik instance terminates TLS via the configured
# cert resolver on the ${TRAEFIK_NETWORK} network.
services:
db:
image: postgres:16-alpine
container_name: familynido-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: ${FAMILYNIDO_TIMEZONE}
volumes:
- familynido-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
api:
image: ghcr.io/${GHCR_OWNER}/familynido-api:latest
container_name: familynido-api
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
# PUID/PGID let the entrypoint align the in-container `familynido` user
# with the host owner of any bind-mounted directory. Leave the .env
# values empty to keep the default (system UID 100). Set them to a
# specific UID/GID (e.g. 1000:1000) when the files volume is a bind
# mount and you want the writes to appear under that host user.
PUID: ${PUID:-}
PGID: ${PGID:-}
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_HTTP_PORTS: "8080"
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
ConnectionStrings__Postgres: Host=db;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
Oidc__Authority: ${OIDC_AUTHORITY}
Oidc__ClientId: ${OIDC_CLIENT_ID}
Oidc__ClientSecret: ${OIDC_CLIENT_SECRET}
Oidc__RequireHttpsMetadata: ${OIDC_REQUIRE_HTTPS}
Family__DefaultTimeZone: ${FAMILYNIDO_TIMEZONE}
Cors__AllowedOrigins__0: ${FAMILYNIDO_BASE_URL}
Calendar__GoogleClientId: ${GOOGLE_CLIENT_ID}
Calendar__GoogleClientSecret: ${GOOGLE_CLIENT_SECRET}
Calendar__OAuthRedirectUri: ${GOOGLE_OAUTH_REDIRECT_URI}
Email__Enabled: ${EMAIL__ENABLED:-false}
Email__SmtpHost: ${EMAIL__SMTPHOST:-}
Email__SmtpPort: ${EMAIL__SMTPPORT:-587}
Email__SmtpUsername: ${EMAIL__SMTPUSERNAME:-}
Email__SmtpPassword: ${EMAIL__SMTPPASSWORD:-}
Email__SmtpUseStartTls: ${EMAIL__SMTPUSESTARTTLS:-true}
Email__From: ${EMAIL__FROM:-FamilyNido <noreply@familynido.example.com>}
Email__AppBaseUrl: ${EMAIL__APPBASEURL:-${FAMILYNIDO_BASE_URL}}
Email__InvitationLifetime: ${EMAIL__INVITATIONLIFETIME:-7.00:00:00}
TZ: ${FAMILYNIDO_TIMEZONE}
volumes:
- familynido-files:/app/data/files
# Persist ASP.NET Core Data Protection keys across container recreations.
# Without this, every `docker compose up -d` rotates the keys and all
# active OIDC cookies (session + nonce/correlation) become unreadable.
- familynido-keys:/home/familynido/.aspnet/DataProtection-Keys
networks:
- internal
labels:
- com.centurylinklabs.watchtower.enable=true
web:
image: ghcr.io/${GHCR_OWNER}/familynido-web:latest
container_name: familynido-web
restart: unless-stopped
depends_on:
- api
networks:
- internal
- traefik
labels:
- com.centurylinklabs.watchtower.enable=true
- traefik.docker.network=${TRAEFIK_NETWORK}
- traefik.http.routers.${TRAEFIK_ROUTER_NAME}.rule=Host(`${TRAEFIK_HOST}`)
- traefik.http.routers.${TRAEFIK_ROUTER_NAME}.tls=true
- traefik.http.routers.${TRAEFIK_ROUTER_NAME}.tls.certresolver=${TRAEFIK_TLS_RESOLVER}
- traefik.http.services.${TRAEFIK_ROUTER_NAME}.loadbalancer.server.port=8080
volumes:
familynido-db-data:
familynido-files:
familynido-keys:
networks:
internal:
driver: bridge
traefik:
name: ${TRAEFIK_NETWORK}
external: true

135
deploy/nginx/default.conf Normal file
View file

@ -0,0 +1,135 @@
server {
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# We sit behind Traefik on a different scheme/port. nginx defaults to
# `absolute_redirect on`, which would rewrite a `return 302 /es/;` to an
# absolute `http://<server_name>:8080/es/` — and the browser would then
# follow that to the internal port, bypassing the proxy.
# Sending only the path lets the browser resolve it against the public
# URL it actually used (https://familia.example.com/...).
absolute_redirect off;
# Bigger proxy buffers for the API responses. The /signin-oidc 302 returns
# a session cookie chunked across `familynido.session*` cookies, easily
# 610 KB of headers. With the default 48 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 38 MB range and would otherwise fail silently.
client_max_body_size 12m;
# Gzip text assets; PWA manifest explicitly included.
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json
application/manifest+json image/svg+xml text/xml;
# Aggressive caching for hashed bundle files (Angular outputHashing=all).
location ~* \.(?:js|css|woff2?|ttf|otf|eot|ico|svg|png|jpg|jpeg|gif|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Service worker registration script and config must never be cached.
location ~* (ngsw-worker\.js|ngsw\.json|safety-worker\.js|worker-basic\.min\.js|manifest\.webmanifest)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}
# Pass /api through to the backend container (name "api" in compose).
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Preserve the scheme Traefik announced to the outside world
# (https) instead of overwriting it with nginx's own $scheme (http).
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_read_timeout 60s;
}
# OIDC callback paths handled by the backend. Need the same forwarded
# headers as /api/ so the OIDC middleware validates the callback against
# the public host/scheme and the AuthenticationProperties round-trip works.
location = /signin-oidc {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location = /signout-callback-oidc {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# ── Locale routing (i18n subpath layout) ────────────────────────────────
# Build emits two bundles: dist/.../browser/es/ and dist/.../browser/en/.
# The Dockerfile copies the entire `browser/` tree to /usr/share/nginx/html
# so the container ends up with /usr/share/nginx/html/{es,en}/index.html.
#
# Root visit: pick es or en from Accept-Language and redirect. Anyone
# bookmarked at /es/... or /en/... hits the SPA below directly.
location = / {
# Negotiate locale from Accept-Language. Defaults to es-ES (the
# source locale) for any browser that doesn't explicitly prefer
# English. Add more `if` branches as locales grow.
if ($http_accept_language ~* "^en") {
return 302 /en/;
}
return 302 /es/;
}
# SPA fallback per locale: any unknown path under /es/ or /en/ falls
# through to that bundle's index.html so the Angular router takes over.
location /es/ {
try_files $uri $uri/ /es/index.html;
}
location /en/ {
try_files $uri $uri/ /en/index.html;
}
# Catch-all for unprefixed paths. Anything we didn't match above (and
# that isn't /api/ or one of the OIDC callbacks) is a stray client
# request — typically a stale `returnUrl` like `/home` from before the
# /es//en/ split. Redirect it through to the Spanish bundle so the
# SPA can take over instead of nginx returning its default 404.
location / {
if ($http_accept_language ~* "^en") { return 302 /en$request_uri; }
return 302 /es$request_uri;
}
}

32
deploy/web.Dockerfile Normal file
View file

@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1.7
# FamilyNido frontend — Angular 21 PWA served by nginx.
# Multi-stage: build bundle with Node, then ship static files on nginx:alpine.
ARG NODE_VERSION=22
ARG NGINX_VERSION=1.27
# ─── Build stage ────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION}-alpine AS build
WORKDIR /src
COPY web/familynido-web/package.json web/familynido-web/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY web/familynido-web/ ./
RUN npx ng build --configuration production
# ─── Runtime stage ──────────────────────────────────────────────────────────
FROM nginx:${NGINX_VERSION}-alpine AS runtime
# Non-root nginx worker.
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/familynido-web/browser/ /usr/share/nginx/html/
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]

View file

@ -0,0 +1,66 @@
# Integrations — FamilyNido public API
FamilyNido exposes a small set of versioned endpoints under `/api/v1/**`
so any external integration (an automation runner, n8n, IFTTT, a cron
job on another machine…) can create tasks in the family's dashboard.
Authentication is by **API key**, minted from the FamilyNido admin UI
(*My account → API keys for integrations*, admins only). The key is
sent with every request:
```
X-Api-Key: bxn_xxxxxxxxxxxxxxxxxxxxxxxx
```
or, alternatively, as `Authorization: Bearer bxn_…`.
## Available endpoints
### `POST /api/v1/tasks` — create a task
Body (JSON):
| Field | Type | Required | Default | Notes |
| - | - | - | - | - |
| `title` | string | yes | — | Up to 200 characters. |
| `category` | string | no | `"General"` | Up to 50 characters. |
| `points` | int | no | `5` | Range 0100. |
| `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.

View file

@ -0,0 +1,34 @@
# Screenshots
This folder hosts the images embedded in the main [`README.md`](../../README.md).
Drop the following PNGs in here and the README will render them automatically.
## Recommended capture flow
To avoid screenshotting real family data, use the **demo seed** built into the
API (see the *"Seed a curated demo family"* section in the root README). It
drops a fictitious Smith family with tasks, a wall thread, meal plan, school
schedule and adults' weekly agenda — everything you need to fill the three
captures below without touching personal data. Reset between attempts with
`docker compose -f deploy/docker-compose.dev.yml down -v`.
| File | Used in section | Suggested capture |
| ------------------------ | -------------------------- | ----------------------------------------------------------------------- |
| `hero-dashboard.png` | Lead (before Highlights) | The Home dashboard at desktop width with a few widgets populated. |
| `tablet-mode.png` | 📺 Tablet mode | The full-screen rotating tablet view, ideally landscape 16:9. |
| `wall.png` | 💬 Wall | A wall post with a reaction or comment so the social bits are visible. |
| `meals.png` | 🍽️ Meals | The weekly meal-plan grid with at least one full week filled in. |
## Capture tips
- **Width**: aim for 16001920 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

BIN
docs/screenshots/meals.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

BIN
docs/screenshots/wall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

6
global.json Normal file
View file

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

View file

@ -0,0 +1,723 @@
using FamilyNido.Api.Shared.Markdown;
using FamilyNido.Domain.Agenda;
using FamilyNido.Domain.Families;
using FamilyNido.Domain.HouseholdTasks;
using FamilyNido.Domain.Identity;
using FamilyNido.Domain.Meals;
using FamilyNido.Domain.School;
using FamilyNido.Domain.Wall;
using FamilyNido.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Bootstrap;
/// <summary>
/// Hosted service that drops a curated, screenshot-friendly scenario into an
/// empty database when running under the <c>Development</c> environment with
/// <c>Seed:Demo:Enabled=true</c>. Exists so the README screenshots can be
/// captured against a coherent fake family without anybody having to expose
/// their real data.
/// </summary>
/// <remarks>
/// <para>Idempotent at the family level: if a family with the configured name
/// already exists, the seeder bails. Re-seeding from scratch is therefore a
/// matter of recreating the dev volume
/// (<c>docker compose -f deploy/docker-compose.dev.yml down -v &amp;&amp; up -d</c>)
/// and restarting the API.</para>
/// <para>The seeder anchors every date to "today" in the family's configured
/// time zone so the captured screenshots feel current regardless of when they
/// are taken — tasks pending today are actually pending today, completions
/// from the past week truly fall in the past week, etc.</para>
/// </remarks>
public sealed class DemoDataSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly DemoSeedOptions _options;
private readonly ILogger<DemoDataSeeder> _logger;
/// <summary>Primary constructor.</summary>
public DemoDataSeeder(
IServiceProvider services,
IOptions<DemoSeedOptions> options,
ILogger<DemoDataSeeder> logger)
{
_services = services;
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled)
{
return;
}
if (string.IsNullOrEmpty(_options.AdminPassword))
{
_logger.LogWarning(
"Demo seeder enabled but Seed:Demo:AdminPassword is empty — skipping.");
return;
}
await using var scope = _services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var hasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<User>>();
var markdown = scope.ServiceProvider.GetRequiredService<MarkdownRenderer>();
var alreadySeeded = await db.Families
.AnyAsync(f => f.Name == _options.FamilyName, cancellationToken);
if (alreadySeeded)
{
_logger.LogInformation(
"Demo seeder: family {FamilyName} already exists — skipping seed.",
_options.FamilyName);
return;
}
// Anchor every relative date to "today" in the family's timezone so the
// screenshots stay coherent regardless of where they are captured.
var tz = TimeZoneInfo.FindSystemTimeZoneById(_options.TimeZone);
var todayLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz);
var today = DateOnly.FromDateTime(todayLocal.DateTime);
_logger.LogInformation(
"Demo seeder: bootstrapping family {FamilyName} (today={Today}, tz={TimeZone}).",
_options.FamilyName, today, _options.TimeZone);
var family = await SeedFamilyAsync(db, cancellationToken);
var members = await SeedMembersAsync(db, hasher, family, today, cancellationToken);
await SeedTasksAsync(db, family, members, today, cancellationToken);
await SeedWallAsync(db, markdown, family, members, cancellationToken);
await SeedMealsAsync(db, family, today, cancellationToken);
await SeedSchoolAsync(db, family, members, today, cancellationToken);
await SeedAgendaAsync(db, family, members, cancellationToken);
_logger.LogInformation(
"Demo seeder: done. Log in as {AdminEmail} to browse.",
_options.AdminEmail);
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private async Task<Family> SeedFamilyAsync(
ApplicationDbContext db,
CancellationToken cancellationToken)
{
var family = new Family
{
Name = _options.FamilyName,
TimeZone = _options.TimeZone,
Locale = _options.Locale,
Latitude = _options.Latitude,
Longitude = _options.Longitude,
LocationLabel = _options.LocationLabel,
};
db.Families.Add(family);
await db.SaveChangesAsync(cancellationToken);
return family;
}
private async Task<DemoMembers> SeedMembersAsync(
ApplicationDbContext db,
IPasswordHasher<User> hasher,
Family family,
DateOnly today,
CancellationToken cancellationToken)
{
var dan = await CreateAdultAsync(
db, hasher, family,
_options.AdminEmail, _options.AdminDisplayName, _options.AdminPassword,
colorHex: "#3B82F6", role: FamilyRole.Admin, cancellationToken);
FamilyMember eve;
if (!string.IsNullOrEmpty(_options.PartnerPassword))
{
eve = await CreateAdultAsync(
db, hasher, family,
_options.PartnerEmail, _options.PartnerDisplayName, _options.PartnerPassword,
colorHex: "#10B981", role: FamilyRole.Adult, cancellationToken);
}
else
{
eve = new FamilyMember
{
FamilyId = family.Id,
DisplayName = _options.PartnerDisplayName,
MemberType = MemberType.Adult,
ColorHex = "#10B981",
ContactEmail = _options.PartnerEmail,
};
db.FamilyMembers.Add(eve);
await db.SaveChangesAsync(cancellationToken);
}
// Kids: derive credible ages from "today" so the screenshots look
// consistent independently of the calendar year.
var alice = new FamilyMember
{
FamilyId = family.Id,
DisplayName = "Alice",
MemberType = MemberType.Child,
ColorHex = "#EC4899",
BirthDate = today.AddYears(-9).AddMonths(-2),
};
var bob = new FamilyMember
{
FamilyId = family.Id,
DisplayName = "Bob",
MemberType = MemberType.Child,
ColorHex = "#F59E0B",
BirthDate = today.AddYears(-6).AddMonths(-4),
};
db.FamilyMembers.AddRange(alice, bob);
await db.SaveChangesAsync(cancellationToken);
return new DemoMembers(dan, eve, alice, bob);
}
private async Task<FamilyMember> CreateAdultAsync(
ApplicationDbContext db,
IPasswordHasher<User> hasher,
Family family,
string email,
string displayName,
string password,
string colorHex,
FamilyRole role,
CancellationToken cancellationToken)
{
var user = new User
{
Email = email,
DisplayName = displayName,
Role = role,
PreferredLanguage = _options.PreferredLanguage,
};
db.Users.Add(user);
await db.SaveChangesAsync(cancellationToken);
db.UserCredentials.Add(new UserCredential
{
UserId = user.Id,
Provider = IdentityProvider.Local,
PasswordHash = hasher.HashPassword(user, password),
});
var member = new FamilyMember
{
FamilyId = family.Id,
DisplayName = displayName,
MemberType = MemberType.Adult,
ColorHex = colorHex,
ContactEmail = email,
UserId = user.Id,
};
db.FamilyMembers.Add(member);
await db.SaveChangesAsync(cancellationToken);
return member;
}
private static async Task SeedTasksAsync(
ApplicationDbContext db,
Family family,
DemoMembers m,
DateOnly today,
CancellationToken cancellationToken)
{
// Mix of recurring + single-shot + floating so each tab on the tasks
// page has visible content, plus a tail of past completions that feeds
// the scoreboard widget.
var trash = new HouseholdTask
{
FamilyId = family.Id,
Title = "Take out the trash",
Category = "Home",
Recurrence = RecurrenceMode.Daily,
StartDate = today.AddDays(-14),
Points = 2,
CreatedByMemberId = m.Dan.Id,
};
var dishwasher = new HouseholdTask
{
FamilyId = family.Id,
Title = "Empty the dishwasher",
Category = "Kitchen",
Recurrence = RecurrenceMode.Daily,
StartDate = today.AddDays(-14),
Points = 3,
CreatedByMemberId = m.Eve.Id,
};
var vacuum = new HouseholdTask
{
FamilyId = family.Id,
Title = "Vacuum the living room",
Category = "Cleaning",
Recurrence = RecurrenceMode.Weekly,
WeeklyDays = DayOfWeekMask.Saturday,
StartDate = today.AddDays(-30),
Points = 5,
CreatedByMemberId = m.Dan.Id,
};
var plants = new HouseholdTask
{
FamilyId = family.Id,
Title = "Water the plants",
Category = "Home",
Recurrence = RecurrenceMode.Weekly,
WeeklyDays = DayOfWeekMask.Wednesday | DayOfWeekMask.Saturday,
StartDate = today.AddDays(-30),
Points = 2,
CreatedByMemberId = m.Eve.Id,
};
var bathroom = new HouseholdTask
{
FamilyId = family.Id,
Title = "Clean the bathroom",
Category = "Cleaning",
Recurrence = RecurrenceMode.Weekly,
WeeklyDays = DayOfWeekMask.Sunday,
StartDate = today.AddDays(-30),
Points = 5,
CreatedByMemberId = m.Dan.Id,
};
var rent = new HouseholdTask
{
FamilyId = family.Id,
Title = "Pay the rent",
Category = "Bills",
Recurrence = RecurrenceMode.Monthly,
MonthlyDay = 1,
StartDate = new DateOnly(today.Year, today.Month, 1).AddMonths(-3),
Points = 5,
CreatedByMemberId = m.Dan.Id,
};
var ballet = new HouseholdTask
{
FamilyId = family.Id,
Title = "Pick up Alice from ballet",
Category = "Kids",
Recurrence = RecurrenceMode.None,
StartDate = today,
DueDate = today,
Points = 3,
CreatedByMemberId = m.Eve.Id,
ResponsibleMemberId = m.Eve.Id,
};
var bread = new HouseholdTask
{
FamilyId = family.Id,
Title = "Buy bread on the way home",
Category = "Errands",
Recurrence = RecurrenceMode.None,
StartDate = today,
IsFloating = true,
Points = 1,
CreatedByMemberId = m.Dan.Id,
};
var grandma = new HouseholdTask
{
FamilyId = family.Id,
Title = "Call grandma",
Category = "Family",
Recurrence = RecurrenceMode.None,
StartDate = today,
DueDate = today.AddDays(1),
Points = 2,
CreatedByMemberId = m.Eve.Id,
};
db.HouseholdTasks.AddRange(trash, dishwasher, vacuum, plants, bathroom, rent, ballet, bread, grandma);
await db.SaveChangesAsync(cancellationToken);
// Past completions sprinkled across the last two weeks so the
// scoreboard has data and the per-task history feels lived-in.
var now = DateTimeOffset.UtcNow;
var completions = new List<TaskCompletion>
{
// Dan is on the trash this week.
new() { TaskId = trash.Id, OccurrenceDate = today.AddDays(-1), CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddDays(-1) },
new() { TaskId = trash.Id, OccurrenceDate = today.AddDays(-3), CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddDays(-3) },
new() { TaskId = trash.Id, OccurrenceDate = today.AddDays(-5), CompletedByMemberId = m.Eve.Id, CompletedAt = now.AddDays(-5) },
// Dishwasher rotates between adults.
new() { TaskId = dishwasher.Id, OccurrenceDate = today, CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddHours(-3) },
new() { TaskId = dishwasher.Id, OccurrenceDate = today.AddDays(-1), CompletedByMemberId = m.Eve.Id, CompletedAt = now.AddDays(-1).AddHours(-2) },
new() { TaskId = dishwasher.Id, OccurrenceDate = today.AddDays(-2), CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddDays(-2) },
new() { TaskId = dishwasher.Id, OccurrenceDate = today.AddDays(-4), CompletedByMemberId = m.Eve.Id, CompletedAt = now.AddDays(-4) },
// Plants — Eve watered last Saturday.
new() { TaskId = plants.Id, OccurrenceDate = LastWeekday(today, DayOfWeek.Saturday).AddDays(-7), CompletedByMemberId = m.Eve.Id, CompletedAt = now.AddDays(-8) },
// Bathroom — Dan two Sundays ago.
new() { TaskId = bathroom.Id, OccurrenceDate = LastWeekday(today, DayOfWeek.Sunday).AddDays(-7), CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddDays(-10) },
// Rent — Dan paid this month.
new() { TaskId = rent.Id, OccurrenceDate = new DateOnly(today.Year, today.Month, 1), CompletedByMemberId = m.Dan.Id, CompletedAt = now.AddDays(-today.Day + 1) },
// Kid helping out.
new() { TaskId = trash.Id, OccurrenceDate = today.AddDays(-7), CompletedByMemberId = m.Alice.Id, CompletedAt = now.AddDays(-7) },
new() { TaskId = trash.Id, OccurrenceDate = today.AddDays(-10), CompletedByMemberId = m.Bob.Id, CompletedAt = now.AddDays(-10) },
};
db.TaskCompletions.AddRange(completions);
await db.SaveChangesAsync(cancellationToken);
}
private static DateOnly LastWeekday(DateOnly anchor, DayOfWeek target)
{
var diff = ((int)anchor.DayOfWeek - (int)target + 7) % 7;
return diff == 0 ? anchor : anchor.AddDays(-diff);
}
private static async Task SeedWallAsync(
ApplicationDbContext db,
MarkdownRenderer markdown,
Family family,
DemoMembers m,
CancellationToken cancellationToken)
{
var memberCandidates = new List<MentionCandidate>
{
new(m.Dan.Id, m.Dan.DisplayName),
new(m.Eve.Id, m.Eve.DisplayName),
new(m.Alice.Id, m.Alice.DisplayName),
new(m.Bob.Id, m.Bob.DisplayName),
};
var now = DateTimeOffset.UtcNow;
var post1Source = "Grandma's coming over for dinner on Saturday — any dietary requests?";
var post1 = await CreateWallMessageAsync(
db, markdown, family, m.Dan, post1Source, isPinned: false,
createdAt: now.AddDays(-3), memberCandidates, cancellationToken);
await AddCommentAsync(db, markdown, post1, m.Eve,
"Maybe skip the cilantro this time, Alice doesn't love it 🌿",
now.AddDays(-3).AddHours(2), memberCandidates, cancellationToken);
await AddCommentAsync(db, markdown, post1, m.Alice,
"And lots of dessert please 🍰",
now.AddDays(-2).AddHours(-4), memberCandidates, cancellationToken);
AddReactions(db, post1, new[]
{
(m.Eve.Id, "❤️"),
(m.Alice.Id, "🎉"),
(m.Bob.Id, "🎉"),
}, now);
var post2Source = """
**🛒 Shopping list this week**
- Milk, eggs, sourdough
- Bananas, apples, lentils
- Olive oil, tomato sauce
- Toilet paper (we're almost out)
""";
var post2 = await CreateWallMessageAsync(
db, markdown, family, m.Eve, post2Source, isPinned: true,
createdAt: now.AddDays(-2), memberCandidates, cancellationToken);
AddReactions(db, post2, new[]
{
(m.Dan.Id, "👍"),
}, now);
var post3Source = "Alice's ballet recital is on the **30th**! 🩰 Mark your calendars.";
var post3 = await CreateWallMessageAsync(
db, markdown, family, m.Eve, post3Source, isPinned: false,
createdAt: now.AddDays(-1), memberCandidates, cancellationToken);
AddReactions(db, post3, new[]
{
(m.Dan.Id, "🎉"),
(m.Alice.Id, "❤️"),
}, now);
var post4Source = "Anybody up for bouldering tomorrow afternoon? Could rope Bob into trying 🧗";
var post4 = await CreateWallMessageAsync(
db, markdown, family, m.Dan, post4Source, isPinned: false,
createdAt: now.AddHours(-5), memberCandidates, cancellationToken);
await AddCommentAsync(db, markdown, post4, m.Eve,
"I'm in 💪",
now.AddHours(-2), memberCandidates, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
}
private static async Task<WallMessage> CreateWallMessageAsync(
ApplicationDbContext db,
MarkdownRenderer markdown,
Family family,
FamilyMember author,
string source,
bool isPinned,
DateTimeOffset createdAt,
IReadOnlyList<MentionCandidate> candidates,
CancellationToken cancellationToken)
{
var rendered = markdown.RenderWithMentions(source, candidates);
var message = new WallMessage
{
FamilyId = family.Id,
AuthorMemberId = author.Id,
Text = rendered.Markdown,
TextHtml = rendered.Html,
IsPinned = isPinned,
PinnedAt = isPinned ? createdAt : null,
};
db.WallMessages.Add(message);
await db.SaveChangesAsync(cancellationToken);
// The DbContext audit-stamping overrides CreatedAt to "now" on insert,
// which would collapse every demo post into the same instant and ruin
// the chronological feed. Back-date in a second roundtrip that skips
// the SaveChanges pipeline so the screenshots show a credible spread.
await db.WallMessages
.Where(m => m.Id == message.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(m => m.CreatedAt, createdAt)
.SetProperty(m => m.UpdatedAt, (DateTimeOffset?)null),
cancellationToken);
return message;
}
private static async Task AddCommentAsync(
ApplicationDbContext db,
MarkdownRenderer markdown,
WallMessage message,
FamilyMember author,
string source,
DateTimeOffset createdAt,
IReadOnlyList<MentionCandidate> candidates,
CancellationToken cancellationToken)
{
var rendered = markdown.RenderWithMentions(source, candidates);
var comment = new WallComment
{
MessageId = message.Id,
AuthorMemberId = author.Id,
Text = rendered.Markdown,
TextHtml = rendered.Html,
};
db.WallComments.Add(comment);
await db.SaveChangesAsync(cancellationToken);
// Same backdating dance as wall messages — comments are ordered by
// CreatedAt under their parent message, and we want each to land
// visibly after the post it replies to.
await db.WallComments
.Where(c => c.Id == comment.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(c => c.CreatedAt, createdAt)
.SetProperty(c => c.UpdatedAt, (DateTimeOffset?)null),
cancellationToken);
}
private static void AddReactions(
ApplicationDbContext db,
WallMessage message,
IEnumerable<(Guid MemberId, string Emoji)> reactions,
DateTimeOffset at)
{
foreach (var (memberId, emoji) in reactions)
{
db.WallReactions.Add(new WallReaction
{
MessageId = message.Id,
MemberId = memberId,
Emoji = emoji,
ReactedAt = at,
});
}
}
private static async Task SeedMealsAsync(
ApplicationDbContext db,
Family family,
DateOnly today,
CancellationToken cancellationToken)
{
// Anchor the meal plan to the Monday of the current week so the
// screenshots always show a full Mon-Sun grid no matter the weekday.
var monday = LastWeekday(today, DayOfWeek.Monday);
var slots = new[]
{
(monday.AddDays(0), MealSlot.Lunch, "Tomato salad", "Pasta carbonara"),
(monday.AddDays(0), MealSlot.Dinner, "Vegetable soup", "Grilled cheese"),
(monday.AddDays(1), MealSlot.Lunch, (string?)null, "Lentil stew"),
(monday.AddDays(1), MealSlot.Dinner, (string?)null, "Sushi takeout 🍣"),
(monday.AddDays(2), MealSlot.Lunch, "Caesar salad", "Roast chicken & potatoes"),
(monday.AddDays(2), MealSlot.Dinner, (string?)null, "Veggie omelette"),
(monday.AddDays(3), MealSlot.Lunch, (string?)null, "Beef tacos"),
(monday.AddDays(3), MealSlot.Dinner, "Greek yoghurt", "Buddha bowl"),
(monday.AddDays(4), MealSlot.Lunch, "Bruschetta", "Margherita pizza 🍕"),
(monday.AddDays(4), MealSlot.Dinner, (string?)null, "Leftovers"),
(monday.AddDays(5), MealSlot.Lunch, "Gazpacho", "Grandma's paella"),
(monday.AddDays(5), MealSlot.Dinner, (string?)null, "Cheese & charcuterie"),
(monday.AddDays(6), MealSlot.Lunch, (string?)null, "Roast lamb"),
(monday.AddDays(6), MealSlot.Dinner, (string?)null, "Tomato & basil soup"),
};
foreach (var (date, slot, first, second) in slots)
{
db.MealPlanSlots.Add(new MealPlanSlot
{
FamilyId = family.Id,
Date = date,
Slot = slot,
FirstCourse = first,
SecondCourse = second,
});
}
await db.SaveChangesAsync(cancellationToken);
}
private static async Task SeedSchoolAsync(
ApplicationDbContext db,
Family family,
DemoMembers m,
DateOnly today,
CancellationToken cancellationToken)
{
db.SchoolProfiles.AddRange(
new SchoolProfile
{
FamilyMemberId = m.Alice.Id,
SchoolName = "Lincoln Elementary",
Grade = "3rd grade",
Tutor = "Ms. Johnson",
TransportMode = TransportMode.Walk,
MorningTime = new TimeOnly(8, 30),
AfternoonTime = new TimeOnly(16, 0),
},
new SchoolProfile
{
FamilyMemberId = m.Bob.Id,
SchoolName = "Lincoln Elementary",
Grade = "Kindergarten",
Tutor = "Mr. Lopez",
TransportMode = TransportMode.Walk,
MorningTime = new TimeOnly(8, 30),
AfternoonTime = new TimeOnly(13, 0),
});
// Weekly drop-off / pick-up routine, Mon-Fri. Dan walks them in, Eve
// picks them up — credible distribution that also shows both adults.
foreach (var dow in new[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday })
{
db.SchoolDaySchedules.Add(new SchoolDaySchedule
{
FamilyMemberId = m.Alice.Id,
DayOfWeek = dow,
DropoffMemberId = m.Dan.Id,
PickupMemberId = m.Eve.Id,
});
db.SchoolDaySchedules.Add(new SchoolDaySchedule
{
FamilyMemberId = m.Bob.Id,
DayOfWeek = dow,
DropoffMemberId = m.Dan.Id,
PickupMemberId = m.Eve.Id,
});
}
// One extracurricular per kid so the school overview has something
// visible without overwhelming the screenshot.
db.Extracurriculars.AddRange(
new Extracurricular
{
FamilyId = family.Id,
FamilyMemberId = m.Alice.Id,
Name = "Ballet",
Location = "Lincoln Dance Studio",
WeeklyDays = DayOfWeekMask.Tuesday | DayOfWeekMask.Thursday,
StartTime = new TimeOnly(17, 0),
EndTime = new TimeOnly(18, 0),
StartDate = today.AddMonths(-2),
DefaultDropoffMemberId = m.Eve.Id,
DefaultPickupMemberId = m.Eve.Id,
},
new Extracurricular
{
FamilyId = family.Id,
FamilyMemberId = m.Bob.Id,
Name = "Swimming",
Location = "Community Pool",
WeeklyDays = DayOfWeekMask.Wednesday,
StartTime = new TimeOnly(17, 30),
EndTime = new TimeOnly(18, 30),
StartDate = today.AddMonths(-2),
DefaultDropoffMemberId = m.Dan.Id,
DefaultPickupMemberId = m.Dan.Id,
});
// One holiday in the near future to populate the "next break" UI.
db.SchoolHolidays.Add(new SchoolHoliday
{
FamilyId = family.Id,
Label = "Spring break",
StartDate = today.AddDays(21),
EndDate = today.AddDays(28),
});
await db.SaveChangesAsync(cancellationToken);
}
private static async Task SeedAgendaAsync(
ApplicationDbContext db,
Family family,
DemoMembers m,
CancellationToken cancellationToken)
{
// Weekly "who's where" patterns so the dashboard agenda widget has
// signal. Kept minimal: working hours for the adults plus one evening
// activity each.
var workdays = new[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday };
foreach (var dow in workdays)
{
db.MemberAgendaPatterns.Add(new MemberAgendaPattern
{
FamilyId = family.Id,
FamilyMemberId = m.Dan.Id,
DayOfWeek = dow,
Label = "Office",
Location = "Downtown",
StartTime = new TimeOnly(9, 0),
EndTime = new TimeOnly(18, 0),
TransportMode = AgendaTransportMode.Bus,
IsAway = true,
});
}
foreach (var dow in new[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday })
{
db.MemberAgendaPatterns.Add(new MemberAgendaPattern
{
FamilyId = family.Id,
FamilyMemberId = m.Eve.Id,
DayOfWeek = dow,
Label = "Studio",
Location = "Old town",
StartTime = new TimeOnly(10, 0),
EndTime = new TimeOnly(19, 0),
TransportMode = AgendaTransportMode.Other,
IsAway = true,
});
}
db.MemberAgendaPatterns.Add(new MemberAgendaPattern
{
FamilyId = family.Id,
FamilyMemberId = m.Eve.Id,
DayOfWeek = DayOfWeek.Wednesday,
Label = "Pilates",
Location = "Wellness Center",
StartTime = new TimeOnly(19, 30),
EndTime = new TimeOnly(20, 30),
TransportMode = AgendaTransportMode.Walk,
IsAway = true,
});
await db.SaveChangesAsync(cancellationToken);
}
/// <summary>Bundle of seeded members so the rest of the seeder can refer to
/// them by role without juggling four parameters everywhere.</summary>
private sealed record DemoMembers(FamilyMember Dan, FamilyMember Eve, FamilyMember Alice, FamilyMember Bob);
}

View file

@ -0,0 +1,67 @@
namespace FamilyNido.Api.Bootstrap;
/// <summary>
/// Strongly-typed binding for the <c>Seed:Demo</c> configuration section.
/// Drives <see cref="DemoDataSeeder"/>, which only registers in the
/// <c>Development</c> environment and no-ops unless <see cref="Enabled"/> is
/// true. Used to drop a curated, screenshot-friendly scenario into an empty
/// database so the README assets can be captured without touching real data.
/// </summary>
public sealed class DemoSeedOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Seed:Demo";
/// <summary>Master switch. The seeder no-ops unless this is true.</summary>
public bool Enabled { get; init; }
/// <summary>Display name of the demo family. Idempotency key — re-running with
/// the same name is a no-op once the family exists.</summary>
public string FamilyName { get; init; } = "Smith Family";
/// <summary>IANA time zone for the demo family. The seeder anchors all dates
/// (tasks, meals, completions) to "today" in this zone so screenshots taken
/// in different time zones still look coherent.</summary>
public string TimeZone { get; init; } = "Europe/Madrid";
/// <summary>BCP-47 locale used for date/number formatting in the UI. The
/// per-user <c>PreferredLanguage</c> drives the actual UI language; pick
/// <c>en-US</c> here if your screenshots target an English README.</summary>
public string Locale { get; init; } = "en-US";
/// <summary>Preferred language each demo adult ends up with. Same hint as
/// <see cref="Locale"/> — overrides at the user level.</summary>
public string PreferredLanguage { get; init; } = "en-US";
/// <summary>Human-readable label rendered under the weather widget.</summary>
public string LocationLabel { get; init; } = "Bilbao";
/// <summary>Geographic latitude in decimal degrees. Drives the weather widget.</summary>
public double Latitude { get; init; } = 43.2630;
/// <summary>Geographic longitude in decimal degrees.</summary>
public double Longitude { get; init; } = -2.9350;
/// <summary>Email used for the primary adult (admin). Required to be able to
/// log in and capture screenshots; the password lives in
/// <see cref="AdminPassword"/>.</summary>
public string AdminEmail { get; init; } = "dan@familynido.demo";
/// <summary>Display name of the primary adult.</summary>
public string AdminDisplayName { get; init; } = "Dan";
/// <summary>Local-credential password for the admin. Required when
/// <see cref="Enabled"/> is true.</summary>
public string AdminPassword { get; init; } = string.Empty;
/// <summary>Email of the second adult.</summary>
public string PartnerEmail { get; init; } = "eve@familynido.demo";
/// <summary>Display name of the second adult.</summary>
public string PartnerDisplayName { get; init; } = "Eve";
/// <summary>Local-credential password for the second adult. Optional — if
/// empty, the partner is created without local credentials (you can still
/// browse as the admin).</summary>
public string PartnerPassword { get; init; } = string.Empty;
}

View file

@ -0,0 +1,45 @@
namespace FamilyNido.Api.Bootstrap;
/// <summary>
/// Strongly-typed binding for the <c>Seed:E2E</c> configuration section.
/// Only honored when <see cref="IHostEnvironment.EnvironmentName"/> is
/// <c>Testing</c>; production never reads this section.
/// </summary>
public sealed class E2ESeedOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Seed:E2E";
/// <summary>Master switch. The seeder no-ops unless this is true.</summary>
public bool Enabled { get; init; }
/// <summary>Name of the test family. Used as the idempotency key.</summary>
public string FamilyName { get; init; } = "FamilyNido E2E";
/// <summary>IANA time zone for the seeded family.</summary>
public string TimeZone { get; init; } = "Europe/Madrid";
/// <summary>Email of admin tester A. Stable across runs.</summary>
public string UserAEmail { get; init; } = "e2e-a@FamilyNido.test";
/// <summary>Local-credential password for tester A. Required when <see cref="Enabled"/> is true.</summary>
public string UserAPassword { get; init; } = string.Empty;
/// <summary>Display name for tester A.</summary>
public string UserADisplayName { get; init; } = "Tester A";
/// <summary>Hex color for tester A's avatar.</summary>
public string UserAColorHex { get; init; } = "#3B82F6";
/// <summary>Email of adult tester B.</summary>
public string UserBEmail { get; init; } = "e2e-b@FamilyNido.test";
/// <summary>Local-credential password for tester B. Required when <see cref="Enabled"/> is true.</summary>
public string UserBPassword { get; init; } = string.Empty;
/// <summary>Display name for tester B.</summary>
public string UserBDisplayName { get; init; } = "Tester B";
/// <summary>Hex color for tester B's avatar.</summary>
public string UserBColorHex { get; init; } = "#EF4444";
}

View file

@ -0,0 +1,149 @@
using FamilyNido.Domain.Families;
using FamilyNido.Domain.Identity;
using FamilyNido.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Bootstrap;
/// <summary>
/// Hosted service that, on startup, ensures a deterministic family + two
/// adult users (tester A as admin, tester B as adult) exist with local
/// credentials. Used solely by the Playwright E2E suite both locally and
/// in CI; production registration is gated on
/// <c>Environment == "Testing"</c> in <c>Program.cs</c>, and the seeder
/// itself bails unless <see cref="E2ESeedOptions.Enabled"/> is true.
/// </summary>
/// <remarks>
/// Idempotent. Safe to run on every startup: existing rows are reused, so
/// repeated runs do not duplicate the family/users and do not rotate
/// already-set passwords.
/// </remarks>
public sealed class E2ETestDataSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly E2ESeedOptions _options;
private readonly ILogger<E2ETestDataSeeder> _logger;
/// <summary>Primary constructor.</summary>
public E2ETestDataSeeder(
IServiceProvider services,
IOptions<E2ESeedOptions> options,
ILogger<E2ETestDataSeeder> logger)
{
_services = services;
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled)
{
return;
}
if (string.IsNullOrEmpty(_options.UserAPassword) || string.IsNullOrEmpty(_options.UserBPassword))
{
_logger.LogWarning("E2E seeder enabled but Seed:E2E:UserAPassword or UserBPassword is empty — skipping.");
return;
}
await using var scope = _services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var hasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<User>>();
var family = await db.Families
.FirstOrDefaultAsync(f => f.Name == _options.FamilyName, cancellationToken);
if (family is null)
{
family = new Family
{
Name = _options.FamilyName,
TimeZone = _options.TimeZone,
};
db.Families.Add(family);
await db.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"E2E seeder: created family {FamilyName} ({FamilyId}).",
family.Name, family.Id);
}
await EnsureUserAsync(
db, hasher, family.Id,
_options.UserAEmail, _options.UserADisplayName,
_options.UserAPassword, _options.UserAColorHex,
FamilyRole.Admin, cancellationToken);
await EnsureUserAsync(
db, hasher, family.Id,
_options.UserBEmail, _options.UserBDisplayName,
_options.UserBPassword, _options.UserBColorHex,
FamilyRole.Adult, cancellationToken);
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private async Task EnsureUserAsync(
ApplicationDbContext db,
IPasswordHasher<User> hasher,
Guid familyId,
string email,
string displayName,
string password,
string colorHex,
FamilyRole role,
CancellationToken cancellationToken)
{
var emailLower = email.ToLowerInvariant();
var user = await db.Users
.Include(u => u.Credentials)
.Include(u => u.FamilyMember)
.FirstOrDefaultAsync(u => u.Email.ToLower() == emailLower, cancellationToken);
if (user is null)
{
user = new User
{
Email = email,
DisplayName = displayName,
Role = role,
};
db.Users.Add(user);
await db.SaveChangesAsync(cancellationToken);
}
var localCred = user.Credentials.FirstOrDefault(c => c.Provider == IdentityProvider.Local);
if (localCred is null)
{
db.UserCredentials.Add(new UserCredential
{
UserId = user.Id,
Provider = IdentityProvider.Local,
PasswordHash = hasher.HashPassword(user, password),
});
await db.SaveChangesAsync(cancellationToken);
}
if (user.FamilyMember is null)
{
db.FamilyMembers.Add(new FamilyMember
{
FamilyId = familyId,
DisplayName = displayName,
MemberType = MemberType.Adult,
ColorHex = colorHex,
UserId = user.Id,
});
await db.SaveChangesAsync(cancellationToken);
}
_logger.LogInformation("E2E seeder: ensured user {Email} ({Role}).", email, role);
}
}

View file

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Google.Apis.Auth" Version="1.69.0" />
<PackageReference Include="Google.Apis.Calendar.v3" Version="1.69.0.3746" />
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Markdig" Version="0.41.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FamilyNido.Domain\FamilyNido.Domain.csproj" />
<ProjectReference Include="..\FamilyNido.Persistence\FamilyNido.Persistence.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,181 @@
using FamilyNido.Api.Options;
using FamilyNido.Api.Shared.Http;
using FamilyNido.Api.Shared.Mediator;
using FluentValidation;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Features.Auth;
/// <summary>Endpoints for the authentication dance (OIDC, local login, credentials, me).</summary>
public static class AuthEndpoints
{
/// <summary>Rate-limit policy name applied to <c>POST /api/auth/local/login</c>.</summary>
public const string LocalLoginRateLimitPolicy = "local-login";
/// <summary>Registers the /api/auth endpoints on the given route group.</summary>
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth").WithTags("Auth");
// Anonymous discovery endpoint: lets the login screen know whether the
// OIDC button should be shown. Treats an empty Authority as "OIDC
// disabled" — which is the default for the dev stack, where the family
// signs in with local credentials.
group.MapGet("/providers", (IOptions<OidcOptions> oidc) =>
{
var enabled = !string.IsNullOrWhiteSpace(oidc.Value.Authority);
return Results.Ok(new { oidcEnabled = enabled });
}).AllowAnonymous();
// OIDC challenge & cookie sign-out. Returns 404 when no Authority is
// configured so that a misclick on a cached login button doesn't
// surface a 500 from the auth middleware.
group.MapGet("/login", (string? returnUrl, IOptions<OidcOptions> oidc) =>
{
if (string.IsNullOrWhiteSpace(oidc.Value.Authority))
{
return Results.NotFound(new { error = "oidc_disabled" });
}
// returnUrl is reflected into the OIDC AuthenticationProperties and
// becomes the post-login redirect target. Restrict to local paths
// ("/something", not "//evil.com" or "https://evil.com") so an
// attacker can't craft `/api/auth/login?returnUrl=…` that ends up
// sending the freshly-signed-in user to a phishing page.
returnUrl = SanitizeLocalReturnUrl(returnUrl);
return Results.Challenge(
new AuthenticationProperties { RedirectUri = returnUrl },
[OpenIdConnectDefaults.AuthenticationScheme]);
}).AllowAnonymous();
// Drop the local cookie unconditionally. We *don't* drive a federated
// OIDC sign-out from here: not every session originates from PocketID
// (local-credentials users have no id_token) and the redirect flow
// doesn't compose well with the SPA's POST-from-fetch logout. If we
// ever need full SLO, a separate /logout/federated endpoint can opt
// into SignOutAsync(OpenIdConnectDefaults...).
group.MapPost("/logout", async (HttpContext http) =>
{
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.NoContent();
}).RequireAuthorization();
group.MapGet("/me", GetMeAsync).RequireAuthorization(Policies.AuthenticatedUser);
group.MapPut("/me/preferred-language", UpdatePreferredLanguageAsync)
.RequireAuthorization(Policies.AuthenticatedUser);
// Local credentials.
group.MapPost("/local/login", LocalLoginAsync)
.AllowAnonymous()
.RequireRateLimiting(LocalLoginRateLimitPolicy);
group.MapPost("/local/set-password", SetLocalPasswordAsync)
.RequireAuthorization(Policies.AuthenticatedUser);
group.MapGet("/credentials", ListCredentialsAsync)
.RequireAuthorization(Policies.AuthenticatedUser);
group.MapDelete("/credentials/{id:guid}", RemoveCredentialAsync)
.RequireAuthorization(Policies.AuthenticatedUser);
return app;
}
// A return URL is "safe" when it is a same-origin absolute path. Reject
// schemes, network-relative URLs ("//host"), and anything missing the
// leading slash; fall back to "/" so the redirect always lands inside
// the SPA.
private static string SanitizeLocalReturnUrl(string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return "/";
}
if (!candidate.StartsWith('/') || candidate.StartsWith("//", StringComparison.Ordinal))
{
return "/";
}
return candidate;
}
private static async Task<IResult> GetMeAsync(IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new GetMe.Query(), ct);
return result.IsSuccess
? Results.Ok(result.Value)
: result.Error.ToHttpResult();
}
private static async Task<IResult> LocalLoginAsync(
LocalLogin.Command command,
IValidator<LocalLogin.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null)
{
return validation;
}
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess
? Results.Ok(result.Value)
: result.Error.ToHttpResult();
}
private static async Task<IResult> SetLocalPasswordAsync(
SetLocalPassword.Command command,
IValidator<SetLocalPassword.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null)
{
return validation;
}
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess
? Results.NoContent()
: result.Error.ToHttpResult();
}
private static async Task<IResult> ListCredentialsAsync(IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new ListMyCredentials.Query(), ct);
return result.IsSuccess
? Results.Ok(result.Value)
: result.Error.ToHttpResult();
}
private static async Task<IResult> RemoveCredentialAsync(Guid id, IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new RemoveCredential.Command(id), ct);
return result.IsSuccess
? Results.NoContent()
: result.Error.ToHttpResult();
}
private static async Task<IResult> UpdatePreferredLanguageAsync(
UpdateMyPreferredLanguage.Command command,
IValidator<UpdateMyPreferredLanguage.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null)
{
return validation;
}
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess
? Results.Ok(result.Value)
: result.Error.ToHttpResult();
}
}

View file

@ -0,0 +1,160 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using FamilyNido.Api.Features.Integrations;
using FamilyNido.Api.Options;
using FamilyNido.Domain.Families;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace FamilyNido.Api.Features.Auth;
/// <summary>Composition of authentication/authorization for the API.</summary>
public static class AuthenticationExtensions
{
/// <summary>
/// Wires the cookie session, the integration API-key handler, the FamilyNido
/// policy set, and — when an OIDC <see cref="OidcOptions.Authority"/> is
/// configured — the OpenID Connect challenge handler. The OIDC scheme is
/// registered conditionally so that a deployment using only local
/// credentials doesn't trip <c>OpenIdConnectOptions.Validate()</c> on every
/// request the moment the auth middleware materializes its handlers.
/// </summary>
public static IServiceCollection AddFamilyNidoAuthentication(
this IServiceCollection services,
IConfiguration configuration,
IWebHostEnvironment environment)
{
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
// Peek at the bound options to decide whether the OIDC scheme is on.
// We can't resolve IOptions here (the provider isn't built yet), but
// we can bind a temporary instance from the same section.
var oidcOptions = new OidcOptions();
configuration.GetSection(OidcOptions.SectionName).Bind(oidcOptions);
var oidcEnabled = !string.IsNullOrWhiteSpace(oidcOptions.Authority);
var authBuilder = services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// Only point the challenge/sign-out chain at OIDC when it's
// actually wired up. With OIDC off, an unauthenticated request
// to a [Authorize] endpoint stays on the cookie scheme, which
// emits a 401 (or hits OnRedirectToLogin for non-API paths).
if (oidcEnabled)
{
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
options.DefaultSignOutScheme = OpenIdConnectDefaults.AuthenticationScheme;
}
})
.AddCookie(options =>
{
options.Cookie.Name = "familynido.session";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
// Force the Secure flag in production so the cookie is never
// sent over plain HTTP; SameAsRequest in dev/Testing keeps the
// local HTTP loop and the in-process WebApplicationFactory
// workflow working without TLS.
options.Cookie.SecurePolicy = environment.IsProduction()
? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
options.Events.OnRedirectToLogin = ctx =>
{
if (ctx.Request.Path.StartsWithSegments("/api"))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
ctx.Response.Redirect(ctx.RedirectUri);
return Task.CompletedTask;
};
})
.AddScheme<IntegrationApiKeyAuthenticationOptions, IntegrationApiKeyAuthenticationHandler>(
IntegrationApiKeyDefaults.AuthenticationScheme,
_ => { });
if (oidcEnabled)
{
authBuilder.AddOpenIdConnect(oidc =>
{
oidc.ResponseType = OpenIdConnectResponseType.Code;
oidc.UsePkce = true;
// SaveTokens=false: the API never re-uses access/refresh
// tokens after the initial callback, so persisting them in
// the session cookie just bloats every request header.
// Keeping them out shrinks the cookie by 48 KB and avoids
// tripping reverse-proxy header limits on every callback.
oidc.SaveTokens = false;
oidc.GetClaimsFromUserInfoEndpoint = true;
oidc.MapInboundClaims = false;
oidc.TokenValidationParameters.NameClaimType = "name";
oidc.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
oidc.Events.OnTokenValidated = EnrichWithRoleClaimAsync;
});
// Apply config values to OpenIdConnectOptions via IConfigureOptions so we
// don't have to build an intermediate provider.
services.AddOptions<OpenIdConnectOptions>(OpenIdConnectDefaults.AuthenticationScheme)
.Configure<IOptions<OidcOptions>>((oidc, options) =>
{
var config = options.Value;
oidc.Authority = config.Authority;
oidc.ClientId = config.ClientId;
oidc.ClientSecret = string.IsNullOrEmpty(config.ClientSecret) ? null : config.ClientSecret;
oidc.RequireHttpsMetadata = config.RequireHttpsMetadata;
oidc.CallbackPath = config.CallbackPath;
oidc.SignedOutCallbackPath = config.SignedOutCallbackPath;
oidc.Scope.Clear();
foreach (var scope in config.Scopes)
{
oidc.Scope.Add(scope);
}
});
}
services.AddAuthorizationBuilder()
.AddFamilyNidoPolicies();
services.AddScoped<ICurrentUserContext, CurrentUserContext>();
return services;
}
private static async Task EnrichWithRoleClaimAsync(TokenValidatedContext ctx)
{
var identity = (ClaimsIdentity?)ctx.Principal?.Identity;
var subject = ctx.Principal?.FindFirst("sub")?.Value;
if (identity is null || subject is null || ctx.Principal is null)
{
return;
}
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, subject));
// Use ResolveAsync(principal): at this point HttpContext.User is still
// anonymous, so GetAsync would skip the DB work and the role claim
// baked into the session cookie would always be Guest.
var userContext = ctx.HttpContext.RequestServices.GetRequiredService<ICurrentUserContext>();
var resolved = await userContext.ResolveAsync(ctx.Principal, ctx.HttpContext.RequestAborted);
var role = resolved?.User.Role ?? FamilyRole.Guest;
identity.AddClaim(new Claim(ClaimTypes.Role, role.ToString()));
// Stamp the cookie with the internal user id so subsequent requests can
// resolve by primary key instead of joining through credentials. Works
// for orphan users too: their cookie is valid even without a member,
// which lets the front route them to the "ask the admin" screen with
// full identity context.
if (resolved is not null)
{
identity.AddClaim(new Claim(CurrentUserContext.UserIdClaimType, resolved.User.Id.ToString()));
}
}
}

View file

@ -0,0 +1,261 @@
using System.Data;
using System.Security.Claims;
using FamilyNido.Api.Options;
using FamilyNido.Domain.Families;
using FamilyNido.Domain.Identity;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Features.Auth;
/// <summary>
/// Default implementation of <see cref="ICurrentUserContext"/>. Caches the
/// lookup per request (it is registered scoped) so repeated calls within a
/// single HTTP request do not re-hit the database.
/// </summary>
public sealed class CurrentUserContext : ICurrentUserContext
{
/// <summary>Custom claim type holding the internal <see cref="User"/> id (Guid string).</summary>
public const string UserIdClaimType = "userId";
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ApplicationDbContext _db;
private readonly TimeProvider _timeProvider;
private readonly FamilyOptions _familyOptions;
private CurrentUser? _cached;
private bool _resolved;
/// <summary>Primary constructor.</summary>
public CurrentUserContext(
IHttpContextAccessor httpContextAccessor,
ApplicationDbContext db,
TimeProvider timeProvider,
IOptions<FamilyOptions> familyOptions)
{
_httpContextAccessor = httpContextAccessor;
_db = db;
_timeProvider = timeProvider;
_familyOptions = familyOptions.Value;
}
/// <inheritdoc />
public async Task<User?> GetUserAsync(CancellationToken cancellationToken)
{
var principal = _httpContextAccessor.HttpContext?.User;
if (principal?.Identity?.IsAuthenticated != true)
{
return null;
}
return await ResolvePrincipalUserAsync(principal, cancellationToken);
}
/// <inheritdoc />
public async Task<CurrentUser?> GetAsync(CancellationToken cancellationToken)
{
if (_resolved)
{
return _cached;
}
_resolved = true;
var principal = _httpContextAccessor.HttpContext?.User;
if (principal?.Identity?.IsAuthenticated != true)
{
return _cached = null;
}
var user = await ResolvePrincipalUserAsync(principal, cancellationToken);
// Orphan users (no FamilyMember linked yet) get the same null treatment as
// unauthenticated callers from the handlers' perspective. The /api/auth/me
// slice translates this into a 403 auth.not_linked so the front can route
// them to the "ask the admin for an invitation" screen.
if (user?.FamilyMember?.Family is null)
{
return _cached = null;
}
return _cached = new CurrentUser(user, user.FamilyMember, user.FamilyMember.Family);
}
/// <inheritdoc />
public async Task<ResolvedIdentity?> ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken)
{
var subject = ExtractOidcSubject(principal);
if (string.IsNullOrWhiteSpace(subject))
{
return null;
}
var email = principal.FindFirst(ClaimTypes.Email)?.Value
?? principal.FindFirst("email")?.Value
?? "";
var displayName = principal.FindFirst("name")?.Value
?? principal.FindFirst(ClaimTypes.Name)?.Value
?? (email.Contains('@', StringComparison.Ordinal)
? email.Split('@', 2)[0]
: subject);
var credential = await _db.UserCredentials
.Include(c => c.User)
.ThenInclude(u => u!.FamilyMember)
.ThenInclude(m => m!.Family)
.FirstOrDefaultAsync(
c => c.Provider == IdentityProvider.Oidc && c.ProviderKey == subject,
cancellationToken);
if (credential is { User: not null })
{
var existing = credential.User;
existing.LastLoginAt = _timeProvider.GetUtcNow();
credential.LastUsedAt = _timeProvider.GetUtcNow();
await _db.SaveChangesAsync(cancellationToken);
return new ResolvedIdentity(existing, existing.FamilyMember, existing.FamilyMember?.Family);
}
// First-ever login path. The "is the DB empty?" check followed by the
// family + admin insert needs to be atomic — without serialization,
// two concurrent OIDC callbacks could both see an empty Users table
// and each spawn a separate family marked admin. A Serializable
// transaction makes Postgres reject one of the two with a 40001
// serialization_failure; that request reads the now-non-empty state
// on retry and falls into the orphan branch as intended.
await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
var alreadyInitialized = await _db.Users.AnyAsync(cancellationToken);
if (!alreadyInitialized && _familyOptions.BootstrapFirstUserAsAdmin)
{
var identity = await BootstrapFirstAdminAsync(subject, email, displayName, cancellationToken);
await tx.CommitAsync(cancellationToken);
return identity;
}
// New OIDC subject on an already-initialized instance. Persist an orphan
// User + its credential so an admin can list and invite them later, and
// so the cookie can carry a stable `userId` claim. The caller (GetMe)
// still translates "no FamilyMember" into 403 auth.not_linked.
var orphan = await CreateOrphanUserAsync(subject, email, displayName, cancellationToken);
await tx.CommitAsync(cancellationToken);
return orphan;
}
private async Task<ResolvedIdentity> BootstrapFirstAdminAsync(
string subject,
string email,
string displayName,
CancellationToken ct)
{
var newFamily = new Family
{
Name = _familyOptions.DefaultName,
TimeZone = _familyOptions.DefaultTimeZone,
Locale = _familyOptions.DefaultLocale,
};
var newUser = new User
{
Email = email,
DisplayName = displayName,
Role = FamilyRole.Admin,
LastLoginAt = _timeProvider.GetUtcNow(),
};
var newCredential = new UserCredential
{
UserId = newUser.Id,
User = newUser,
Provider = IdentityProvider.Oidc,
ProviderKey = subject,
LastUsedAt = _timeProvider.GetUtcNow(),
};
var newMember = new FamilyMember
{
FamilyId = newFamily.Id,
Family = newFamily,
DisplayName = displayName,
MemberType = MemberType.Adult,
ColorHex = "#C96442",
ContactEmail = string.IsNullOrWhiteSpace(email) ? null : email,
User = newUser,
};
_db.Families.Add(newFamily);
_db.Users.Add(newUser);
_db.UserCredentials.Add(newCredential);
_db.FamilyMembers.Add(newMember);
await _db.SaveChangesAsync(ct);
return new ResolvedIdentity(newUser, newMember, newFamily);
}
private async Task<ResolvedIdentity> CreateOrphanUserAsync(
string subject,
string email,
string displayName,
CancellationToken ct)
{
var newUser = new User
{
Email = email,
DisplayName = displayName,
Role = FamilyRole.Guest,
LastLoginAt = _timeProvider.GetUtcNow(),
};
var newCredential = new UserCredential
{
UserId = newUser.Id,
User = newUser,
Provider = IdentityProvider.Oidc,
ProviderKey = subject,
LastUsedAt = _timeProvider.GetUtcNow(),
};
_db.Users.Add(newUser);
_db.UserCredentials.Add(newCredential);
await _db.SaveChangesAsync(ct);
return new ResolvedIdentity(newUser, null, null);
}
/// <summary>
/// Resolves the persisted <see cref="User"/> for an authenticated principal.
/// Prefers the fast <c>userId</c> claim baked into the cookie at login time;
/// falls back to the OIDC <c>sub</c> claim via <see cref="UserCredential"/>
/// for legacy sessions issued before this claim was added.
/// </summary>
private async Task<User?> ResolvePrincipalUserAsync(ClaimsPrincipal principal, CancellationToken ct)
{
var userIdClaim = principal.FindFirst(UserIdClaimType)?.Value;
if (Guid.TryParse(userIdClaim, out var userId))
{
return await _db.Users
.AsNoTracking()
.Include(u => u.FamilyMember)
.ThenInclude(m => m!.Family)
.FirstOrDefaultAsync(u => u.Id == userId, ct);
}
var subject = ExtractOidcSubject(principal);
if (string.IsNullOrWhiteSpace(subject))
{
return null;
}
return await _db.Users
.AsNoTracking()
.Include(u => u.FamilyMember)
.ThenInclude(m => m!.Family)
.FirstOrDefaultAsync(
u => u.Credentials.Any(c => c.Provider == IdentityProvider.Oidc && c.ProviderKey == subject),
ct);
}
private static string? ExtractOidcSubject(ClaimsPrincipal principal)
=> principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst("sub")?.Value;
}

View file

@ -0,0 +1,46 @@
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
namespace FamilyNido.Api.Features.Auth;
/// <summary>Slice for <c>GET /api/auth/me</c>: returns the profile of the caller.</summary>
public static class GetMe
{
/// <summary>Query carrying no input — the caller is resolved from the ambient context.</summary>
public sealed record Query : IRequest<Result<MeDto>>;
/// <summary>Builds the profile DTO from <see cref="ICurrentUserContext"/>.</summary>
public sealed class Handler : IRequestHandler<Query, Result<MeDto>>
{
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ICurrentUserContext userContext) => _userContext = userContext;
/// <inheritdoc />
public async Task<Result<MeDto>> HandleAsync(Query request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden(
"auth.not_linked",
"Your account is not linked to a family member. Ask the family admin to link you.");
}
return new MeDto(
UserId: current.User.Id,
Email: current.User.Email,
DisplayName: current.User.DisplayName,
Role: current.User.Role,
FamilyId: current.Family.Id,
FamilyName: current.Family.Name,
MemberId: current.Member.Id,
MemberDisplayName: current.Member.DisplayName,
ColorHex: current.Member.ColorHex,
PhotoPath: current.Member.PhotoPath,
PreferredLanguage: current.User.PreferredLanguage);
}
}
}

View file

@ -0,0 +1,32 @@
using System.Security.Claims;
using FamilyNido.Persistence;
namespace FamilyNido.Api.Features.Auth;
/// <summary>
/// <see cref="ICurrentActorProvider"/> implementation that reads the
/// authenticated subject from the active <see cref="HttpContext"/>. Falls back
/// to <c>"system"</c> when no request is in flight (background work, startup,
/// design-time). Registered by the API composition root.
/// </summary>
public sealed class HttpContextActorProvider : ICurrentActorProvider
{
private readonly IHttpContextAccessor _accessor;
/// <summary>Primary constructor.</summary>
public HttpContextActorProvider(IHttpContextAccessor accessor) => _accessor = accessor;
/// <inheritdoc />
public string GetActor()
{
var principal = _accessor.HttpContext?.User;
// Prefer the internal userId claim (Guid) so audit trails are stable
// across credential rotations (a user could swap OIDC for local at any
// point, but their User.Id never changes). Fall back to the OIDC sub
// for sessions issued before the userId claim was introduced, and to
// "system" outside HTTP requests.
return principal?.FindFirstValue(CurrentUserContext.UserIdClaimType)
?? principal?.FindFirstValue(ClaimTypes.NameIdentifier)
?? "system";
}
}

View file

@ -0,0 +1,61 @@
using System.Security.Claims;
using FamilyNido.Domain.Families;
using FamilyNido.Domain.Identity;
namespace FamilyNido.Api.Features.Auth;
/// <summary>
/// Per-request context resolving the authenticated caller to their persisted
/// <see cref="User"/>, <see cref="FamilyMember"/> and <see cref="Family"/>.
/// Handles first-login bootstrapping (RF-USR-005, RF-AUTH-003) and stamps
/// the cookie with the internal <c>userId</c> claim used for fast lookups.
/// </summary>
public interface ICurrentUserContext
{
/// <summary>
/// Steady-state lookup against <c>HttpContext.User</c>. Never mutates the
/// database — use this from endpoints and hubs. Returns <c>null</c> when
/// the caller is unauthenticated, the user row is gone, or the user has
/// no <see cref="FamilyMember"/> linked yet (pre-onboarding).
/// </summary>
Task<CurrentUser?> GetAsync(CancellationToken cancellationToken);
/// <summary>
/// Resolves only the <see cref="User"/> row, even when no
/// <see cref="FamilyMember"/> is linked. Used by onboarding flows
/// (accept-invitation) where the caller is authenticated but still orphan.
/// Returns <c>null</c> when the caller is unauthenticated or the row is gone.
/// </summary>
Task<User?> GetUserAsync(CancellationToken cancellationToken);
/// <summary>
/// Provisioning variant used inside the OIDC <c>OnTokenValidated</c> event
/// where <c>HttpContext.User</c> is still anonymous. Bootstraps the first
/// admin + family on a fresh database, creates an orphan <see cref="User"/>
/// (plus its <see cref="UserCredential"/>) for new subjects on an
/// already-initialized instance, or refreshes <c>LastLoginAt</c> on
/// repeated logins.
/// </summary>
/// <returns>
/// The resolved identity when an OIDC subject was present (linked or
/// orphan); <c>null</c> when the principal carries no usable subject.
/// </returns>
Task<ResolvedIdentity?> ResolveAsync(ClaimsPrincipal principal, CancellationToken cancellationToken);
}
/// <summary>
/// Resolved per-request identity used by endpoints and SignalR hubs.
/// </summary>
/// <param name="User">Internal user row.</param>
/// <param name="Member">Linked family member.</param>
/// <param name="Family">Owning family.</param>
public sealed record CurrentUser(User User, FamilyMember Member, Family Family);
/// <summary>
/// Identity resolved during the OIDC callback. <see cref="Member"/> and
/// <see cref="Family"/> may be null while the user waits to be linked.
/// </summary>
/// <param name="User">Internal user row (always present once resolved).</param>
/// <param name="Member">Linked family member, or null when the user is orphan.</param>
/// <param name="Family">Owning family, or null when the user is orphan.</param>
public sealed record ResolvedIdentity(User User, FamilyMember? Member, Family? Family);

View file

@ -0,0 +1,75 @@
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Identity;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Auth;
/// <summary>
/// Slice: the authenticated user inspects their own credentials. Used by the
/// "Mi cuenta" screen to show which login methods are active and to drive
/// the "establecer / cambiar / eliminar" buttons.
/// </summary>
public static class ListMyCredentials
{
/// <summary>Query — no inputs.</summary>
public sealed record Query : IRequest<Result<IReadOnlyList<CredentialDto>>>;
/// <summary>Handler.</summary>
public sealed class Handler : IRequestHandler<Query, Result<IReadOnlyList<CredentialDto>>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<IReadOnlyList<CredentialDto>>> HandleAsync(Query request, CancellationToken ct)
{
var user = await _userContext.GetUserAsync(ct);
if (user is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "Caller is not authenticated.");
}
var rows = await _db.UserCredentials
.AsNoTracking()
.Where(c => c.UserId == user.Id)
.OrderBy(c => c.Provider)
.ThenByDescending(c => c.CreatedAt)
.ToListAsync(ct);
IReadOnlyList<CredentialDto> dto = [.. rows.Select(c => new CredentialDto(
Id: c.Id,
Provider: c.Provider,
CreatedAt: c.CreatedAt,
LastUsedAt: c.LastUsedAt,
ProviderKeyHint: c.Provider == IdentityProvider.Oidc ? Hint(c.ProviderKey) : null))];
return Result<IReadOnlyList<CredentialDto>>.Success(dto);
}
private static string? Hint(string? key)
=> string.IsNullOrEmpty(key) ? null : key.Length <= 6 ? key : $"…{key[^6..]}";
}
}
/// <summary>Read-model returned by <c>GET /api/auth/credentials</c>.</summary>
/// <param name="Id">Credential id.</param>
/// <param name="Provider">Provider type.</param>
/// <param name="CreatedAt">UTC instant the credential was added.</param>
/// <param name="LastUsedAt">UTC instant of last successful login through this credential.</param>
/// <param name="ProviderKeyHint">Last 6 chars of the OIDC subject (for UX disambiguation). Null for Local.</param>
public sealed record CredentialDto(
Guid Id,
IdentityProvider Provider,
DateTimeOffset CreatedAt,
DateTimeOffset? LastUsedAt,
string? ProviderKeyHint);

View file

@ -0,0 +1,138 @@
using System.Security.Claims;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Identity;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Auth;
/// <summary>
/// Slice: anonymous local-credentials login. Returns a generic 401 for both
/// "user does not exist" and "password mismatch" so the endpoint cannot be
/// used to enumerate accounts. The endpoint signs the cookie itself instead
/// of going through ASP.NET's challenge dance, so the cookie ends up
/// equivalent in shape to one minted by the OIDC callback (same claim set).
/// </summary>
public static class LocalLogin
{
/// <summary>Command — wire payload from the front.</summary>
/// <param name="Email">Account email (case-insensitive).</param>
/// <param name="Password">Plain-text password.</param>
public sealed record Command(string Email, string Password) : IRequest<Result<LocalLoginResponse>>;
/// <summary>Input validation. Mirrors what the front already checks; backend stays the source of truth.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator with all rules registered.</summary>
public Validator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress().MaximumLength(254);
RuleFor(x => x.Password).NotEmpty().MaximumLength(256);
}
}
/// <summary>Handler.</summary>
public sealed class Handler : IRequestHandler<Command, Result<LocalLoginResponse>>
{
private readonly ApplicationDbContext _db;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly TimeProvider _timeProvider;
/// <summary>Primary constructor.</summary>
public Handler(
ApplicationDbContext db,
IPasswordHasher<User> passwordHasher,
IHttpContextAccessor httpContextAccessor,
TimeProvider timeProvider)
{
_db = db;
_passwordHasher = passwordHasher;
_httpContextAccessor = httpContextAccessor;
_timeProvider = timeProvider;
}
/// <inheritdoc />
public async Task<Result<LocalLoginResponse>> HandleAsync(Command request, CancellationToken ct)
{
var email = request.Email.Trim().ToLowerInvariant();
// Pull the user with its local credential. EmailIndex is unique
// so this is a single index lookup. We materialize the row even
// for non-existing users to keep the timing similar across
// outcomes — minor mitigation against trivial timing oracles.
var user = await _db.Users
.Include(u => u.Credentials)
.FirstOrDefaultAsync(u => u.Email.ToLower() == email, ct);
var credential = user?.Credentials.FirstOrDefault(c => c.Provider == IdentityProvider.Local);
if (user is null || credential is null || string.IsNullOrEmpty(credential.PasswordHash))
{
// Hash the supplied password against a dummy hash so the
// failure path takes a similar amount of time as success.
_passwordHasher.VerifyHashedPassword(new User { Email = "x", DisplayName = "x" }, DummyHash, request.Password);
return ApplicationError.Forbidden("auth.invalid_credentials", "Invalid credentials.");
}
var verification = _passwordHasher.VerifyHashedPassword(user, credential.PasswordHash, request.Password);
if (verification == PasswordVerificationResult.Failed)
{
return ApplicationError.Forbidden("auth.invalid_credentials", "Invalid credentials.");
}
// Quietly upgrade the hash format if the framework recommends it
// (e.g. higher iteration count after a SDK upgrade).
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
credential.PasswordHash = _passwordHasher.HashPassword(user, request.Password);
}
var now = _timeProvider.GetUtcNow();
user.LastLoginAt = now;
credential.LastUsedAt = now;
await _db.SaveChangesAsync(ct);
// Sign the user in directly with the cookie scheme — this is
// shaped exactly like the cookie minted by the OIDC callback so
// CurrentUserContext, policies and audit can resolve it the
// same way (via the userId claim).
var http = _httpContextAccessor.HttpContext
?? throw new InvalidOperationException("LocalLogin requires an HTTP context.");
await SignInAsync(http, user);
return new LocalLoginResponse(user.Id);
}
private static async Task SignInAsync(HttpContext http, User user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(CurrentUserContext.UserIdClaimType, user.Id.ToString()),
new(ClaimTypes.Email, user.Email),
new(ClaimTypes.Name, user.DisplayName),
new(ClaimTypes.Role, user.Role.ToString()),
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
}
// Pre-computed PBKDF2 IdentityV3 hash for the constant string "dummy".
// Used solely to keep timing similar between "no such user" and
// "wrong password" branches; never accepted by production code paths.
private const string DummyHash = "AQAAAAIAAYagAAAAEN7N9wzjC0Q5xLsv6T6bC2jD7M5KZ8Z7VlFpYy5UQS3SkQ8Pxn6M8Tcu1gZ6lGqcYg==";
}
}
/// <summary>Result of <c>POST /api/auth/local/login</c>.</summary>
/// <param name="UserId">Internal user id (front uses this only as a sanity flag).</param>
public sealed record LocalLoginResponse(Guid UserId);

View file

@ -0,0 +1,28 @@
using FamilyNido.Domain.Families;
namespace FamilyNido.Api.Features.Auth;
/// <summary>Profile payload returned by <c>GET /api/auth/me</c>.</summary>
/// <param name="UserId">Internal user identifier.</param>
/// <param name="Email">Email associated with the account.</param>
/// <param name="DisplayName">Name shown in the UI.</param>
/// <param name="Role">Authorization role.</param>
/// <param name="FamilyId">Owning family identifier.</param>
/// <param name="FamilyName">Owning family display name.</param>
/// <param name="MemberId">Linked family member id, if any.</param>
/// <param name="MemberDisplayName">Linked family member display name.</param>
/// <param name="ColorHex">Linked family member color code.</param>
/// <param name="PhotoPath">Relative path to the member's avatar image, if any. Used by the shell to load the photo via <c>/api/family-members/{id}/photo</c>.</param>
/// <param name="PreferredLanguage">BCP-47 tag (e.g. <c>es-ES</c>, <c>en-US</c>) the user picked for the UI. Drives email + integration content.</param>
public sealed record MeDto(
Guid UserId,
string Email,
string DisplayName,
FamilyRole Role,
Guid FamilyId,
string FamilyName,
Guid? MemberId,
string? MemberDisplayName,
string? ColorHex,
string? PhotoPath,
string PreferredLanguage);

View file

@ -0,0 +1,32 @@
using FluentValidation;
namespace FamilyNido.Api.Features.Auth;
/// <summary>
/// Centralized password complexity requirements. Plain enough to remember,
/// strict enough to keep the obvious "1234" / "password" attacks out.
/// Reused by both <see cref="SetLocalPassword"/> and the invitation
/// "accept-local" path.
/// </summary>
internal static class PasswordPolicy
{
/// <summary>Minimum number of characters.</summary>
public const int MinLength = 8;
/// <summary>Maximum length we ever accept (defends the hash function from absurd inputs).</summary>
public const int MaxLength = 256;
/// <summary>
/// Rule helper used by FluentValidation builders.
/// Requires length, at least one letter, and at least one digit.
/// </summary>
public static IRuleBuilderOptions<T, string> Password<T>(this IRuleBuilder<T, string> rule)
{
return rule
.NotEmpty()
.MinimumLength(MinLength).WithMessage($"Password must be at least {MinLength} characters.")
.MaximumLength(MaxLength)
.Must(s => s.Any(char.IsLetter)).WithMessage("Password must contain at least one letter.")
.Must(s => s.Any(char.IsDigit)).WithMessage("Password must contain at least one digit.");
}
}

View file

@ -0,0 +1,39 @@
using FamilyNido.Api.Features.Integrations;
using FamilyNido.Domain.Families;
using Microsoft.AspNetCore.Authorization;
namespace FamilyNido.Api.Features.Auth;
/// <summary>
/// Named authorization policies used across endpoints. Centralizing them keeps
/// policy names discoverable and prevents magic-string drift.
/// </summary>
public static class Policies
{
/// <summary>Admin of the family — can manage members and configuration.</summary>
public const string Admin = "family.admin";
/// <summary>Adult — default access level for authenticated users.</summary>
public const string Adult = "family.adult";
/// <summary>Any authenticated and linked user (includes guests).</summary>
public const string AuthenticatedUser = "family.member";
/// <summary>External integration callers authenticated by an API key.</summary>
public const string Integration = "family.integration";
/// <summary>Registers the policies above against the supplied builder.</summary>
public static AuthorizationBuilder AddFamilyNidoPolicies(this AuthorizationBuilder builder)
{
builder
.AddPolicy(Admin, p => p.RequireRole(nameof(FamilyRole.Admin)))
.AddPolicy(Adult, p => p.RequireRole(nameof(FamilyRole.Admin), nameof(FamilyRole.Adult)))
.AddPolicy(AuthenticatedUser, p => p.RequireAuthenticatedUser())
.AddPolicy(Integration, p => p
.AddAuthenticationSchemes(IntegrationApiKeyDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.RequireClaim(IntegrationClaimTypes.FamilyId)
.RequireClaim(IntegrationClaimTypes.AuthorMemberId));
return builder;
}
}

View file

@ -0,0 +1,62 @@
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Auth;
/// <summary>
/// Slice: an authenticated user deletes one of their own credentials.
/// Refuses to remove the last credential to prevent self-lockout.
/// </summary>
public static class RemoveCredential
{
/// <summary>Command.</summary>
/// <param name="CredentialId">Credential to remove.</param>
public sealed record Command(Guid CredentialId) : IRequest<Result<Unit>>;
/// <summary>Handler.</summary>
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken ct)
{
var user = await _userContext.GetUserAsync(ct);
if (user is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "Caller is not authenticated.");
}
var credential = await _db.UserCredentials
.FirstOrDefaultAsync(c => c.Id == request.CredentialId && c.UserId == user.Id, ct);
if (credential is null)
{
return ApplicationError.NotFound("credential.not_found", "Credential not found.");
}
var totalForUser = await _db.UserCredentials.CountAsync(c => c.UserId == user.Id, ct);
if (totalForUser <= 1)
{
return ApplicationError.Conflict(
"credential.last_remaining",
"Cannot remove the only login method on this account. Add another method first.");
}
_db.UserCredentials.Remove(credential);
await _db.SaveChangesAsync(ct);
return Unit.Value;
}
}
}

View file

@ -0,0 +1,100 @@
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Identity;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Auth;
/// <summary>
/// Slice: an authenticated user sets or rotates their local password.
/// First-time setup (no existing local credential) does not require the
/// current password; rotation does.
/// </summary>
public static class SetLocalPassword
{
/// <summary>Command.</summary>
/// <param name="CurrentPassword">Current password (required when rotating; ignored on first-time setup).</param>
/// <param name="NewPassword">New password.</param>
public sealed record Command(string? CurrentPassword, string NewPassword) : IRequest<Result<Unit>>;
/// <summary>Validator — only checks the new password's complexity here. Current-password verification happens in the handler against the stored hash.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator with all rules registered.</summary>
public Validator()
{
RuleFor(x => x.NewPassword).Password();
}
}
/// <summary>Handler.</summary>
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly TimeProvider _timeProvider;
/// <summary>Primary constructor.</summary>
public Handler(
ApplicationDbContext db,
ICurrentUserContext userContext,
IPasswordHasher<User> passwordHasher,
TimeProvider timeProvider)
{
_db = db;
_userContext = userContext;
_passwordHasher = passwordHasher;
_timeProvider = timeProvider;
}
/// <inheritdoc />
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken ct)
{
var user = await _userContext.GetUserAsync(ct);
if (user is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "Caller is not authenticated.");
}
var existing = await _db.UserCredentials
.FirstOrDefaultAsync(c => c.UserId == user.Id && c.Provider == IdentityProvider.Local, ct);
if (existing is not null)
{
if (string.IsNullOrEmpty(request.CurrentPassword))
{
return ApplicationError.Validation(
"auth.current_password_required",
"Current password is required to rotate the local credential.");
}
var verification = _passwordHasher.VerifyHashedPassword(
user, existing.PasswordHash!, request.CurrentPassword);
if (verification == PasswordVerificationResult.Failed)
{
return ApplicationError.Forbidden("auth.invalid_credentials", "Current password is incorrect.");
}
existing.PasswordHash = _passwordHasher.HashPassword(user, request.NewPassword);
existing.UpdatedAt = _timeProvider.GetUtcNow();
}
else
{
_db.UserCredentials.Add(new UserCredential
{
UserId = user.Id,
Provider = IdentityProvider.Local,
PasswordHash = _passwordHasher.HashPassword(user, request.NewPassword),
});
}
await _db.SaveChangesAsync(ct);
return Unit.Value;
}
}
}

View file

@ -0,0 +1,84 @@
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Auth;
/// <summary>
/// Slice for <c>PUT /api/auth/me/preferred-language</c>. Persists the caller's
/// chosen UI language so digest emails, mention notifications and integration-
/// generated task titles match the bundle they're using on the frontend.
/// </summary>
/// <remarks>
/// The whitelist of accepted tags lives here on purpose — when a new locale
/// is added (frontend bundle + xlf + backend strings) we want the API to
/// reject older or unsupported tags loudly instead of silently storing
/// garbage that no localizer maps.
/// </remarks>
public static class UpdateMyPreferredLanguage
{
/// <summary>Tags accepted by the validator. Keep aligned with the Angular bundles.</summary>
public static readonly string[] AllowedTags = { "es-ES", "en-US" };
/// <summary>Command body carrying the new tag.</summary>
/// <param name="Language">BCP-47 tag from <see cref="AllowedTags"/>.</param>
public sealed record Command(string Language) : IRequest<Result<Response>>;
/// <summary>Echo of the persisted value, used by the frontend to confirm.</summary>
/// <param name="Language">The new tag stored on the user.</param>
public sealed record Response(string Language);
/// <summary>Validator: ensures the requested tag is one we actually support.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator.</summary>
public Validator()
{
RuleFor(x => x.Language)
.NotEmpty()
.Must(value => Array.IndexOf(AllowedTags, value) >= 0)
.WithMessage("Unsupported language tag.");
}
}
/// <summary>Persists the new preferred language on the caller's <c>User</c> row.</summary>
public sealed class Handler : IRequestHandler<Command, Result<Response>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<Response>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var user = await _userContext.GetUserAsync(cancellationToken);
if (user is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "Not signed in.");
}
// The handler only mutates a single column. We re-fetch by id so the
// SaveChanges below operates on a tracked entity inside this scope —
// GetUserAsync may have returned a no-tracking projection.
var tracked = await _db.Users.FirstOrDefaultAsync(u => u.Id == user.Id, cancellationToken);
if (tracked is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "User not found.");
}
tracked.PreferredLanguage = request.Language;
await _db.SaveChangesAsync(cancellationToken);
return new Response(request.Language);
}
}
}

View file

@ -0,0 +1,195 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Options;
using FamilyNido.Api.Shared.Http;
using FamilyNido.Api.Shared.Mediator;
using FluentValidation;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>REST endpoints for the calendar mirror (RF-CAL-*).</summary>
public static class CalendarEndpoints
{
/// <summary>Cookie that round-trips the Google OAuth state between start and callback.</summary>
public const string OAuthStateCookieName = "FamilyNido.calendar.google.oauth-state";
/// <summary>Path under which the state cookie is valid.</summary>
public const string OAuthCookiePath = "/api/calendar/google";
/// <summary>Registers <c>/api/calendar</c> endpoints on the given route group.</summary>
public static IEndpointRouteBuilder MapCalendarEndpoints(this IEndpointRouteBuilder app)
{
var authenticated = app.MapGroup("/api/calendar")
.WithTags("Calendar")
.RequireAuthorization(Policies.AuthenticatedUser);
authenticated.MapGet("/events", ListEventsAsync);
authenticated.MapPut("/events/{eventId:guid}/members", SetEventMembersAsync);
authenticated.MapGet("/accounts", ListAccountsAsync);
authenticated.MapPatch("/calendars/{id:guid}", UpdateCalendarAsync);
authenticated.MapDelete("/accounts/{id:guid}", UnlinkAccountAsync);
authenticated.MapPost("/accounts/{id:guid}/sync", SyncAccountAsync);
authenticated.MapPost("/google/start", StartLinkAsync);
// Callback is open: Google redirects the browser here after the user grants
// consent. The session cookie is presented automatically (SameSite=Lax) and
// the state cookie is what authenticates the dance — not the API auth cookie.
// We still want the user to be authenticated to FamilyNido though, so the slice
// re-validates the user identity inside.
app.MapGet("/api/calendar/google/callback", HandleCallbackAsync)
.WithTags("Calendar");
return app;
}
private static async Task<IResult> ListEventsAsync(
DateTimeOffset from,
DateTimeOffset to,
Guid[]? memberIds,
IValidator<ListEvents.Query> validator,
IMediator mediator,
CancellationToken ct)
{
var query = new ListEvents.Query(from, to, memberIds);
var validation = await validator.ValidateOrProblemAsync(query, ct);
if (validation is not null)
{
return validation;
}
var result = await mediator.SendAsync(query, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> ListAccountsAsync(IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new ListLinkedAccounts.Query(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> SetEventMembersAsync(
Guid eventId,
SetCalendarEventMembersBody body,
IValidator<SetCalendarEventMembers.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var command = new SetCalendarEventMembers.Command(eventId, body.MemberIds ?? []);
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null)
{
return validation;
}
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> UpdateCalendarAsync(
Guid id,
UpdateLinkedCalendarBody body,
IValidator<UpdateLinkedCalendar.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var command = new UpdateLinkedCalendar.Command(id, body.IsImported, body.FamilyMemberId);
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null)
{
return validation;
}
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> UnlinkAccountAsync(Guid id, IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new UnlinkGoogleAccount.Command(id), ct);
return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult();
}
private static async Task<IResult> SyncAccountAsync(Guid id, IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new TriggerManualSync.Command(id), ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> StartLinkAsync(
IMediator mediator,
HttpContext httpContext,
IOptions<CalendarOptions> options,
CancellationToken ct)
{
var result = await mediator.SendAsync(new StartGoogleLink.Command(), ct);
if (!result.IsSuccess)
{
return result.Error.ToHttpResult();
}
// Drop the encrypted state into a short-lived cookie scoped to /api/calendar/google
// so subsequent callback hits carry it back; SameSite=Lax is required for the
// top-level redirect from Google to attach the cookie.
httpContext.Response.Cookies.Append(OAuthStateCookieName, result.Value.EncryptedState, new CookieOptions
{
HttpOnly = true,
Secure = httpContext.Request.IsHttps,
SameSite = SameSiteMode.Lax,
Path = OAuthCookiePath,
Expires = result.Value.ExpiresAt,
});
return Results.Ok(new { authUrl = result.Value.AuthUrl });
}
private static async Task<IResult> HandleCallbackAsync(
string? code,
string? state,
string? error,
IMediator mediator,
HttpContext httpContext,
IOptions<CalendarOptions> options,
CancellationToken ct)
{
var redirectBase = options.Value.PostAuthRedirectPath;
// Always wipe the state cookie so an aborted attempt doesn't linger.
httpContext.Response.Cookies.Delete(OAuthStateCookieName, new CookieOptions { Path = OAuthCookiePath });
if (!string.IsNullOrEmpty(error))
{
return Results.Redirect(AppendError(redirectBase, error));
}
if (string.IsNullOrEmpty(code))
{
return Results.Redirect(AppendError(redirectBase, "missing_code"));
}
var encryptedState = httpContext.Request.Cookies[OAuthStateCookieName];
var command = new HandleGoogleCallback.Command(code, state, encryptedState);
var result = await mediator.SendAsync(command, ct);
if (!result.IsSuccess)
{
return Results.Redirect(AppendError(redirectBase, result.Error.Code));
}
return Results.Redirect(redirectBase + "?linked=" + result.Value.GoogleAccountId);
}
private static string AppendError(string basePath, string code)
{
var separator = basePath.Contains('?') ? '&' : '?';
return $"{basePath}{separator}error={Uri.EscapeDataString(code)}";
}
}
/// <summary>Wire-level body for PATCH /api/calendar/calendars/{id}.</summary>
/// <param name="IsImported">Whether to mirror events from this calendar.</param>
/// <param name="FamilyMemberId">Optional family member to associate (null clears).</param>
public sealed record UpdateLinkedCalendarBody(bool IsImported, Guid? FamilyMemberId);
/// <summary>Wire-level body for PUT /api/calendar/events/{eventId}/members.</summary>
/// <param name="MemberIds">New full set of related members (replaces the previous one). Empty array clears all relations.</param>
public sealed record SetCalendarEventMembersBody(IReadOnlyList<Guid>? MemberIds);

View file

@ -0,0 +1,47 @@
using FamilyNido.Domain.Calendar;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>Wire-level view of a mirrored Google Calendar event.</summary>
/// <param name="Id">FamilyNido id of the event row.</param>
/// <param name="LinkedCalendarId">Calendar of origin within the linked account.</param>
/// <param name="FamilyMemberId">Family member associated to the source calendar (if any) — drives the color in the UI.</param>
/// <param name="Title">Event title.</param>
/// <param name="Description">Optional longer description.</param>
/// <param name="Location">Optional location string.</param>
/// <param name="StartAt">Start instant in UTC.</param>
/// <param name="EndAt">End instant in UTC.</param>
/// <param name="IsAllDay">True when the event has no specific time-of-day.</param>
/// <param name="OriginalTimeZone">Original IANA timezone reported by Google.</param>
/// <param name="HtmlLink">Public link back to the event in Google Calendar.</param>
/// <param name="RelatedMemberIds">Per-event N:M of family members tagged locally on this event (independent of the calendar default).</param>
public sealed record CalendarEventDto(
Guid Id,
Guid LinkedCalendarId,
Guid? FamilyMemberId,
string Title,
string? Description,
string? Location,
DateTimeOffset StartAt,
DateTimeOffset EndAt,
bool IsAllDay,
string? OriginalTimeZone,
string? HtmlLink,
IReadOnlyList<Guid> RelatedMemberIds)
{
/// <summary>Builds a DTO from the persisted entity (assumes <see cref="CalendarEvent.LinkedCalendar"/> and RelatedMembers are loaded).</summary>
public static CalendarEventDto From(CalendarEvent ev)
=> new(
ev.Id,
ev.LinkedCalendarId,
ev.LinkedCalendar?.FamilyMemberId,
ev.Title,
ev.Description,
ev.Location,
ev.StartAt,
ev.EndAt,
ev.IsAllDay,
ev.OriginalTimeZone,
ev.HtmlLink,
[.. ev.RelatedMembers.Select(m => m.Id)]);
}

View file

@ -0,0 +1,81 @@
using FamilyNido.Api.Options;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Hosted service that runs <see cref="CalendarSynchronizer.SyncAllAsync"/> on a
/// fixed cadence (<see cref="CalendarOptions.SyncInterval"/>). Sleeps with a
/// <see cref="PeriodicTimer"/> so the loop wakes up promptly on shutdown.
/// </summary>
public sealed class CalendarSyncBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptionsMonitor<CalendarOptions> _options;
private readonly ILogger<CalendarSyncBackgroundService> _logger;
/// <summary>Primary constructor.</summary>
public CalendarSyncBackgroundService(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<CalendarOptions> options,
ILogger<CalendarSyncBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_options = options;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var initialDelay = TimeSpan.FromSeconds(30);
try
{
// Don't pile work onto a cold-starting app. A short head-start means the API is
// already serving traffic when the first sync runs.
await Task.Delay(initialDelay, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
while (!stoppingToken.IsCancellationRequested)
{
await SyncOnceAsync(stoppingToken);
var interval = _options.CurrentValue.SyncInterval;
if (interval <= TimeSpan.Zero)
{
interval = TimeSpan.FromMinutes(15);
}
try
{
await Task.Delay(interval, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
}
}
private async Task SyncOnceAsync(CancellationToken stoppingToken)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var synchronizer = scope.ServiceProvider.GetRequiredService<CalendarSynchronizer>();
await synchronizer.SyncAllAsync(stoppingToken);
}
catch (OperationCanceledException)
{
// Shutting down; do not log.
}
catch (Exception ex)
{
_logger.LogError(ex, "Calendar sync iteration failed at the top level.");
}
}
}

View file

@ -0,0 +1,319 @@
using FamilyNido.Domain.Calendar;
using FamilyNido.Persistence;
using Google.Apis.Calendar.v3.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Synchronization engine for the calendar mirror. Talks to Google through
/// <see cref="GoogleCalendarClient"/>, upserts into <see cref="ApplicationDbContext"/>,
/// and persists the <c>nextSyncToken</c> so subsequent runs do an incremental delta.
/// Reused by both the periodic background service and the manual-sync endpoint.
/// </summary>
public sealed class CalendarSynchronizer
{
private readonly ApplicationDbContext _db;
private readonly GoogleOAuthService _oauth;
private readonly GoogleCalendarClient _client;
private readonly TimeProvider _clock;
private readonly ILogger<CalendarSynchronizer> _logger;
/// <summary>Primary constructor.</summary>
public CalendarSynchronizer(
ApplicationDbContext db,
GoogleOAuthService oauth,
GoogleCalendarClient client,
TimeProvider clock,
ILogger<CalendarSynchronizer> logger)
{
_db = db;
_oauth = oauth;
_client = client;
_clock = clock;
_logger = logger;
}
/// <summary>
/// Runs a sync for every imported calendar of <paramref name="accountId"/>. Used
/// by the manual-sync endpoint to trigger an account-scoped refresh on demand.
/// </summary>
public async Task SyncAccountAsync(Guid accountId, CancellationToken cancellationToken)
{
var account = await _db.GoogleAccounts
.Include(a => a.Calendars)
.FirstOrDefaultAsync(a => a.Id == accountId, cancellationToken);
if (account is null || account.IsRevoked)
{
return;
}
await SyncAccountInternalAsync(account, cancellationToken);
}
/// <summary>
/// Sweeps every healthy account and calendar. Called by the background timer.
/// Errors on a single calendar are isolated so that one failing account does
/// not block the rest.
/// </summary>
public async Task SyncAllAsync(CancellationToken cancellationToken)
{
var accounts = await _db.GoogleAccounts
.Include(a => a.Calendars)
.Where(a => !a.IsRevoked)
.ToListAsync(cancellationToken);
foreach (var account in accounts)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
await SyncAccountInternalAsync(account, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Sync failed for Google account {AccountId} ({Email})", account.Id, account.Email);
account.LastError = ex.Message;
await _db.SaveChangesAsync(cancellationToken);
}
}
}
private async Task SyncAccountInternalAsync(GoogleAccount account, CancellationToken cancellationToken)
{
string refreshToken;
try
{
refreshToken = _oauth.UnprotectRefreshToken(account.EncryptedRefreshToken);
}
catch (Exception)
{
account.IsRevoked = true;
account.LastError = "Stored refresh token could not be decrypted; re-link required.";
await _db.SaveChangesAsync(cancellationToken);
return;
}
// Probe the credential by refreshing once so we fail fast on revoked tokens
// before iterating calendars.
var refresh = await _oauth.RefreshAccessTokenAsync(refreshToken, cancellationToken);
if (refresh is null)
{
account.IsRevoked = true;
account.LastError = "Google rejected the refresh token (invalid_grant).";
await _db.SaveChangesAsync(cancellationToken);
return;
}
account.LastError = null;
var imported = account.Calendars.Where(c => c.IsImported).ToList();
foreach (var calendar in imported)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
await SyncCalendarAsync(calendar, refreshToken, cancellationToken);
}
await _db.SaveChangesAsync(cancellationToken);
}
private async Task SyncCalendarAsync(LinkedCalendar calendar, string refreshToken, CancellationToken cancellationToken)
{
GoogleEventsPage page;
try
{
page = await _client.ListEventsAsync(
refreshToken,
calendar.ExternalCalendarId,
calendar.SyncToken,
cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "events.list failed for calendar {CalendarId}", calendar.ExternalCalendarId);
return;
}
if (page.FullSyncRequired)
{
// Sync token expired (Gone). Drop everything we had and let the next tick run a full sync.
await _db.CalendarEvents
.Where(e => e.LinkedCalendarId == calendar.Id)
.ExecuteDeleteAsync(cancellationToken);
calendar.SyncToken = null;
calendar.LastSyncedAt = null;
return;
}
var familyId = await ResolveFamilyIdAsync(calendar, cancellationToken);
var existingByExternalId = await _db.CalendarEvents
.Where(e => e.LinkedCalendarId == calendar.Id)
.ToDictionaryAsync(e => e.ExternalEventId, StringComparer.Ordinal, cancellationToken);
foreach (var googleEvent in page.Events)
{
ApplyEvent(googleEvent, calendar, familyId, existingByExternalId);
}
if (!string.IsNullOrEmpty(page.NextSyncToken))
{
calendar.SyncToken = page.NextSyncToken;
}
calendar.LastSyncedAt = _clock.GetUtcNow();
}
private async Task<Guid> ResolveFamilyIdAsync(LinkedCalendar calendar, CancellationToken cancellationToken)
{
// The calendar's owning account is the source of truth for FamilyId. Pull it
// explicitly: relying on calendar.GoogleAccount being attached requires Include.
if (calendar.GoogleAccount is { } loaded)
{
return loaded.FamilyId;
}
return await _db.GoogleAccounts
.Where(a => a.Id == calendar.GoogleAccountId)
.Select(a => a.FamilyId)
.FirstAsync(cancellationToken);
}
private void ApplyEvent(
Event googleEvent,
LinkedCalendar calendar,
Guid familyId,
Dictionary<string, CalendarEvent> existing)
{
if (string.IsNullOrEmpty(googleEvent.Id))
{
return;
}
// status=cancelled means the event was deleted in Google. We mirror that by
// removing the row.
if (string.Equals(googleEvent.Status, "cancelled", StringComparison.OrdinalIgnoreCase))
{
if (existing.TryGetValue(googleEvent.Id, out var doomed))
{
_db.CalendarEvents.Remove(doomed);
existing.Remove(googleEvent.Id);
}
return;
}
if (!TryReadInstants(googleEvent, out var startAt, out var endAt, out var isAllDay, out var timeZone))
{
// Events without start/end are valid in Google's data model (e.g. tasks),
// but the calendar UI cannot render them. Skip silently.
return;
}
if (existing.TryGetValue(googleEvent.Id, out var row))
{
row.Title = googleEvent.Summary ?? "(sin título)";
row.Description = googleEvent.Description;
row.Location = googleEvent.Location;
row.StartAt = startAt;
row.EndAt = endAt;
row.IsAllDay = isAllDay;
row.OriginalTimeZone = timeZone;
row.HtmlLink = googleEvent.HtmlLink;
row.IcalUid = googleEvent.ICalUID;
}
else
{
_db.CalendarEvents.Add(new CalendarEvent
{
FamilyId = familyId,
LinkedCalendarId = calendar.Id,
ExternalEventId = googleEvent.Id,
IcalUid = googleEvent.ICalUID,
Title = googleEvent.Summary ?? "(sin título)",
Description = googleEvent.Description,
Location = googleEvent.Location,
StartAt = startAt,
EndAt = endAt,
IsAllDay = isAllDay,
OriginalTimeZone = timeZone,
HtmlLink = googleEvent.HtmlLink,
});
}
}
private static bool TryReadInstants(
Event googleEvent,
out DateTimeOffset startAt,
out DateTimeOffset endAt,
out bool isAllDay,
out string? timeZone)
{
startAt = default;
endAt = default;
isAllDay = false;
timeZone = googleEvent.Start?.TimeZone ?? googleEvent.End?.TimeZone;
if (googleEvent.Start is null || googleEvent.End is null)
{
return false;
}
if (googleEvent.Start.DateTimeDateTimeOffset is { } startDateTime &&
googleEvent.End.DateTimeDateTimeOffset is { } endDateTime)
{
// Npgsql's timestamptz requires Offset=00:00, so normalize the original
// event offset (Google echoes whatever the author had) to UTC.
startAt = startDateTime.ToUniversalTime();
endAt = endDateTime.ToUniversalTime();
return true;
}
if (!string.IsNullOrEmpty(googleEvent.Start.Date) && !string.IsNullOrEmpty(googleEvent.End.Date))
{
// All-day events come as YYYY-MM-DD strings. Treat them as midnight in the
// event's original timezone, falling back to UTC. End is exclusive (Google convention).
isAllDay = true;
var tz = TryFindTimeZone(timeZone) ?? TimeZoneInfo.Utc;
startAt = ParseAllDay(googleEvent.Start.Date, tz).ToUniversalTime();
endAt = ParseAllDay(googleEvent.End.Date, tz).ToUniversalTime();
return true;
}
return false;
}
private static DateTimeOffset ParseAllDay(string yyyyMmDd, TimeZoneInfo tz)
{
var date = DateOnly.Parse(yyyyMmDd, System.Globalization.CultureInfo.InvariantCulture);
var local = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified);
var offset = tz.GetUtcOffset(local);
return new DateTimeOffset(local, offset);
}
private static TimeZoneInfo? TryFindTimeZone(string? id)
{
if (string.IsNullOrEmpty(id))
{
return null;
}
try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch
{
return null;
}
}
}

View file

@ -0,0 +1,66 @@
using FamilyNido.Domain.Calendar;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>Wire-level view of a linked Google account plus its discovered calendars.</summary>
/// <param name="Id">FamilyNido id of the account row.</param>
/// <param name="Email">Google email — labels the account in the UI.</param>
/// <param name="DisplayName">Best-effort display name reported by Google.</param>
/// <param name="IsRevoked">True when the refresh token was rejected; the user must re-link.</param>
/// <param name="LastError">Last sync error message captured for diagnostics; null when healthy.</param>
/// <param name="LinkedAt">UTC instant the account was linked.</param>
/// <param name="Calendars">Calendars discovered under this account.</param>
public sealed record GoogleAccountDto(
Guid Id,
string Email,
string? DisplayName,
bool IsRevoked,
string? LastError,
DateTimeOffset LinkedAt,
IReadOnlyList<LinkedCalendarDto> Calendars)
{
/// <summary>Builds a DTO from the persisted entity (with eager-loaded calendars).</summary>
public static GoogleAccountDto From(GoogleAccount account)
=> new(
account.Id,
account.Email,
account.DisplayName,
account.IsRevoked,
account.LastError,
account.CreatedAt,
[.. account.Calendars
.OrderBy(c => c.Summary, StringComparer.OrdinalIgnoreCase)
.Select(LinkedCalendarDto.From)]);
}
/// <summary>Wire-level view of a single Google calendar discovered under an account.</summary>
/// <param name="Id">FamilyNido id of the linked-calendar row.</param>
/// <param name="ExternalCalendarId">Google's calendar id (stable across accounts).</param>
/// <param name="Summary">Display name from Google.</param>
/// <param name="Description">Optional description from Google.</param>
/// <param name="ColorHex">Color reported by Google (<c>#RRGGBB</c>).</param>
/// <param name="IsImported">Whether events from this calendar are mirrored.</param>
/// <param name="FamilyMemberId">Optional family member assigned for color attribution.</param>
/// <param name="LastSyncedAt">UTC instant of the last successful sync; null until first sync.</param>
public sealed record LinkedCalendarDto(
Guid Id,
string ExternalCalendarId,
string Summary,
string? Description,
string? ColorHex,
bool IsImported,
Guid? FamilyMemberId,
DateTimeOffset? LastSyncedAt)
{
/// <summary>Builds a DTO from the persisted entity.</summary>
public static LinkedCalendarDto From(LinkedCalendar calendar)
=> new(
calendar.Id,
calendar.ExternalCalendarId,
calendar.Summary,
calendar.Description,
calendar.ColorHex,
calendar.IsImported,
calendar.FamilyMemberId,
calendar.LastSyncedAt);
}

View file

@ -0,0 +1,175 @@
using System.Globalization;
using FamilyNido.Api.Options;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Auth.OAuth2.Flows;
using Google.Apis.Auth.OAuth2.Responses;
using Google.Apis.Calendar.v3;
using Google.Apis.Calendar.v3.Data;
using Google.Apis.Services;
using Google.Apis.Util.Store;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Thin wrapper around the official <c>Google.Apis.Calendar.v3</c> SDK that hides
/// the credential plumbing. Each call accepts a refresh token and builds a fresh
/// in-memory <see cref="UserCredential"/> — no on-disk token cache, no global state.
/// </summary>
public sealed class GoogleCalendarClient
{
private readonly IOptions<CalendarOptions> _options;
/// <summary>Primary constructor.</summary>
public GoogleCalendarClient(IOptions<CalendarOptions> options)
{
_options = options;
}
/// <summary>Lists every calendar visible to the linked Google account.</summary>
/// <param name="refreshToken">Plaintext refresh token of the linked account.</param>
/// <param name="cancellationToken">Cancellation propagated from the caller.</param>
/// <returns>Calendars as Google reports them — caller decides which to mirror.</returns>
public async Task<IReadOnlyList<CalendarListEntry>> ListCalendarsAsync(string refreshToken, CancellationToken cancellationToken)
{
using var service = BuildService(refreshToken);
var request = service.CalendarList.List();
request.MinAccessRole = CalendarListResource.ListRequest.MinAccessRoleEnum.Reader;
var aggregated = new List<CalendarListEntry>();
string? pageToken = null;
do
{
request.PageToken = pageToken;
var page = await request.ExecuteAsync(cancellationToken);
if (page.Items is not null)
{
aggregated.AddRange(page.Items);
}
pageToken = page.NextPageToken;
} while (!string.IsNullOrEmpty(pageToken));
return aggregated;
}
/// <summary>
/// Pages through <c>events.list</c> for a single calendar. When
/// <paramref name="syncToken"/> is non-null, performs an incremental sync; when
/// null, performs a full sync within the configured lookback/lookahead window.
/// </summary>
/// <param name="refreshToken">Plaintext refresh token of the linked account.</param>
/// <param name="externalCalendarId">Google calendar id to enumerate.</param>
/// <param name="syncToken">Latest <c>nextSyncToken</c> received, or null for a full sync.</param>
/// <param name="cancellationToken">Cancellation propagated from the caller.</param>
public async Task<GoogleEventsPage> ListEventsAsync(
string refreshToken,
string externalCalendarId,
string? syncToken,
CancellationToken cancellationToken)
{
using var service = BuildService(refreshToken);
var aggregated = new List<Event>();
string? pageToken = null;
string? nextSyncToken = null;
var fullSyncRequired = false;
do
{
var request = service.Events.List(externalCalendarId);
request.PageToken = pageToken;
// Critical: SingleEvents=true makes Google expand recurrences into individual instances,
// so the FamilyNido sync engine never has to deal with RRULE expansion.
request.SingleEvents = true;
request.ShowDeleted = !string.IsNullOrEmpty(syncToken); // incremental sync needs deletes
request.MaxResults = 250;
if (!string.IsNullOrEmpty(syncToken))
{
request.SyncToken = syncToken;
}
else
{
var now = DateTime.UtcNow;
request.TimeMinDateTimeOffset = now.AddTicks(-_options.Value.FullSyncLookback.Ticks);
request.TimeMaxDateTimeOffset = now.AddTicks(_options.Value.FullSyncLookahead.Ticks);
request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime;
}
try
{
var page = await request.ExecuteAsync(cancellationToken);
if (page.Items is not null)
{
aggregated.AddRange(page.Items);
}
pageToken = page.NextPageToken;
nextSyncToken = page.NextSyncToken ?? nextSyncToken;
}
catch (Google.GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Gone)
{
// 410 Gone = the sync token has expired; signal the caller to drop it
// and run a full sync next iteration.
fullSyncRequired = true;
break;
}
} while (!string.IsNullOrEmpty(pageToken));
return new GoogleEventsPage(aggregated, nextSyncToken, fullSyncRequired);
}
private CalendarService BuildService(string refreshToken)
{
var options = _options.Value;
var flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer
{
ClientSecrets = new ClientSecrets
{
ClientId = options.GoogleClientId,
ClientSecret = options.GoogleClientSecret,
},
Scopes = [CalendarService.Scope.CalendarReadonly],
DataStore = new NullDataStore(),
});
var token = new TokenResponse
{
RefreshToken = refreshToken,
// Force the SDK to fetch a fresh access token on first use.
ExpiresInSeconds = 0,
IssuedUtc = DateTime.UtcNow.AddDays(-1),
};
var credential = new UserCredential(flow, "user", token);
return new CalendarService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "FamilyNido",
});
}
/// <summary>
/// In-memory "data store" so the SDK doesn't try to persist tokens on disk.
/// We hold tokens ourselves (encrypted in Postgres) and rebuild the credential
/// per request.
/// </summary>
private sealed class NullDataStore : IDataStore
{
public Task ClearAsync() => Task.CompletedTask;
public Task DeleteAsync<T>(string key) => Task.CompletedTask;
public Task<T> GetAsync<T>(string key) => Task.FromResult<T>(default!);
public Task StoreAsync<T>(string key, T value) => Task.CompletedTask;
}
}
/// <summary>
/// Result of a <c>events.list</c> page traversal.
/// </summary>
/// <param name="Events">All events from every page returned by Google.</param>
/// <param name="NextSyncToken">Token to persist for the next incremental sync; null when a full sync is still pending.</param>
/// <param name="FullSyncRequired">True when Google replied 410 Gone — the caller must reset its stored sync token and run a full sync next.</param>
public sealed record GoogleEventsPage(
IReadOnlyList<Event> Events,
string? NextSyncToken,
bool FullSyncRequired);

View file

@ -0,0 +1,217 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FamilyNido.Api.Options;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Encapsulates the Google OAuth 2.0 Authorization Code dance for the Calendar
/// integration: builds the authorization URL with a CSRF-resistant state, exchanges
/// the redirected code for tokens, and protects/unprotects the refresh token at
/// rest. Stateless — kept as a singleton because it depends only on options and
/// Data Protection, both of which are themselves singletons.
/// </summary>
public sealed class GoogleOAuthService
{
private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
private const string CalendarScope = "https://www.googleapis.com/auth/calendar.readonly";
private const string OpenIdScope = "openid";
private const string EmailScope = "email";
private const string ProfileScope = "profile";
private readonly IDataProtector _stateProtector;
private readonly IDataProtector _refreshTokenProtector;
private readonly IOptions<CalendarOptions> _options;
private readonly TimeProvider _clock;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>Primary constructor.</summary>
public GoogleOAuthService(
IDataProtectionProvider dataProtectionProvider,
IOptions<CalendarOptions> options,
TimeProvider clock,
IHttpClientFactory httpClientFactory)
{
_stateProtector = dataProtectionProvider.CreateProtector("calendar.google.oauth-state");
_refreshTokenProtector = dataProtectionProvider.CreateProtector("calendar.google.refresh-token");
_options = options;
_clock = clock;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// Builds the Google authorization URL plus the encrypted state payload that the
/// API must round-trip via cookie. The plain nonce is what we hand Google through
/// the <c>state</c> query parameter; the encrypted blob lives in a short-lived
/// cookie and is re-validated on the callback.
/// </summary>
/// <param name="userId">Authenticated FamilyNido user id.</param>
/// <returns>Tuple containing the auth URL, the encrypted cookie payload, and the cookie expiration.</returns>
public (string AuthUrl, string EncryptedState, DateTimeOffset ExpiresAt) BuildAuthorizationRequest(Guid userId)
{
var options = _options.Value;
var nonce = Guid.NewGuid().ToString("N");
var expiresAt = _clock.GetUtcNow().AddMinutes(15);
var statePayload = new GoogleOAuthState(userId, nonce, expiresAt);
var encrypted = _stateProtector.Protect(JsonSerializer.Serialize(statePayload));
var query = new Dictionary<string, string?>
{
["client_id"] = options.GoogleClientId,
["redirect_uri"] = options.OAuthRedirectUri,
["response_type"] = "code",
["scope"] = string.Join(' ', [CalendarScope, OpenIdScope, EmailScope, ProfileScope]),
// offline + consent guarantees a refresh_token even if the user has linked before.
["access_type"] = "offline",
["prompt"] = "consent",
["include_granted_scopes"] = "true",
["state"] = nonce,
};
var authUrl = $"{AuthorizationEndpoint}?{BuildQuery(query)}";
return (authUrl, encrypted, expiresAt);
}
/// <summary>
/// Validates the encrypted state cookie content against the <c>state</c> query
/// parameter Google echoed back. Returns null when the cookie is missing, expired,
/// tampered with, or does not match the query nonce.
/// </summary>
public GoogleOAuthState? ValidateState(string? encryptedCookie, string? queryStateNonce)
{
if (string.IsNullOrEmpty(encryptedCookie) || string.IsNullOrEmpty(queryStateNonce))
{
return null;
}
try
{
var json = _stateProtector.Unprotect(encryptedCookie);
var payload = JsonSerializer.Deserialize<GoogleOAuthState>(json);
if (payload is null)
{
return null;
}
if (payload.ExpiresAt < _clock.GetUtcNow())
{
return null;
}
if (!CryptographicEquals(payload.Nonce, queryStateNonce))
{
return null;
}
return payload;
}
catch
{
// Tampering, key rotation, or malformed payload: treat as invalid.
return null;
}
}
/// <summary>
/// Exchanges the authorization <paramref name="code"/> received on the callback
/// for an access + refresh token pair. Throws on transport or HTTP errors so the
/// caller can surface a clean failure reason.
/// </summary>
public async Task<GoogleTokenResponse> ExchangeCodeAsync(string code, CancellationToken cancellationToken)
{
var options = _options.Value;
using var http = _httpClientFactory.CreateClient(nameof(GoogleOAuthService));
var form = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("client_id", options.GoogleClientId),
new KeyValuePair<string, string>("client_secret", options.GoogleClientSecret),
new KeyValuePair<string, string>("redirect_uri", options.OAuthRedirectUri),
new KeyValuePair<string, string>("grant_type", "authorization_code"),
});
using var response = await http.PostAsync(TokenEndpoint, form, cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<GoogleTokenResponse>(cancellationToken: cancellationToken);
if (payload is null || string.IsNullOrEmpty(payload.AccessToken))
{
throw new InvalidOperationException("Google token endpoint returned an empty response.");
}
return payload;
}
/// <summary>
/// Refreshes the access token for an account using its stored refresh token.
/// Returns null when Google rejects the refresh (revoked credential), so callers
/// can flip <c>GoogleAccount.IsRevoked</c> instead of looping forever.
/// </summary>
public async Task<GoogleTokenResponse?> RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken)
{
var options = _options.Value;
using var http = _httpClientFactory.CreateClient(nameof(GoogleOAuthService));
var form = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("refresh_token", refreshToken),
new KeyValuePair<string, string>("client_id", options.GoogleClientId),
new KeyValuePair<string, string>("client_secret", options.GoogleClientSecret),
new KeyValuePair<string, string>("grant_type", "refresh_token"),
});
using var response = await http.PostAsync(TokenEndpoint, form, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest ||
response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
// Google returns 400 invalid_grant for a revoked or expired refresh token.
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<GoogleTokenResponse>(cancellationToken: cancellationToken);
}
/// <summary>Encrypts a refresh token for storage in <c>google_accounts.encrypted_refresh_token</c>.</summary>
public string ProtectRefreshToken(string plaintext) => _refreshTokenProtector.Protect(plaintext);
/// <summary>Decrypts a refresh token. Throws if the ciphertext was tampered with or the key was rotated past retention.</summary>
public string UnprotectRefreshToken(string ciphertext) => _refreshTokenProtector.Unprotect(ciphertext);
/// <summary>
/// Decodes (without verifying — Google already verified by issuing) the email
/// and display name from a Google id_token JWT. Used after the code exchange to
/// label the linked account in the UI without an extra API call.
/// </summary>
public static (string Email, string? Name) DecodeIdToken(string idToken)
{
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(idToken);
var email = jwt.Claims.FirstOrDefault(c => c.Type == "email")?.Value
?? throw new InvalidOperationException("Google id_token did not contain an email claim.");
var name = jwt.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
return (email, name);
}
private static bool CryptographicEquals(string left, string right)
{
var leftBytes = Encoding.UTF8.GetBytes(left);
var rightBytes = Encoding.UTF8.GetBytes(right);
return CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
}
private static string BuildQuery(IDictionary<string, string?> values)
{
var pairs = values
.Where(kv => kv.Value is not null)
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}");
return string.Join('&', pairs);
}
}

View file

@ -0,0 +1,12 @@
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Payload stored in the encrypted state cookie during the Google OAuth dance.
/// Persisted only for the few minutes between the user clicking "link" and Google
/// redirecting back. Bound to the authenticated user so an attacker cannot reuse
/// a callback for a different identity.
/// </summary>
/// <param name="UserId">FamilyNido user id that initiated the link.</param>
/// <param name="Nonce">Random value also passed to Google as the <c>state</c> query parameter — must round-trip exactly.</param>
/// <param name="ExpiresAt">Hard expiration; callbacks beyond this are rejected.</param>
public sealed record GoogleOAuthState(Guid UserId, string Nonce, DateTimeOffset ExpiresAt);

View file

@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Token endpoint response from <c>https://oauth2.googleapis.com/token</c>. Only
/// the fields we actually consume are surfaced.
/// </summary>
public sealed class GoogleTokenResponse
{
/// <summary>Short-lived OAuth access token (typically 1 hour).</summary>
[JsonPropertyName("access_token")]
public string? AccessToken { get; init; }
/// <summary>
/// Long-lived refresh token. Only returned on the first consent (or when
/// <c>prompt=consent</c> forces re-issuance). Persisted encrypted.
/// </summary>
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; init; }
/// <summary>Identity token containing the verified email and display name.</summary>
[JsonPropertyName("id_token")]
public string? IdToken { get; init; }
/// <summary>Token lifetime in seconds.</summary>
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; init; }
/// <summary>Granted scope (space-separated).</summary>
[JsonPropertyName("scope")]
public string? Scope { get; init; }
}

View file

@ -0,0 +1,189 @@
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Calendar;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Slice: process the Google OAuth callback. Validates the state cookie, exchanges
/// the authorization code for tokens, persists (or refreshes) the
/// <see cref="GoogleAccount"/>, and discovers the visible calendars so the user can
/// choose which to import on the cuentas screen.
/// </summary>
public static class HandleGoogleCallback
{
/// <summary>Inputs harvested from the callback request.</summary>
/// <param name="Code">Authorization code returned by Google.</param>
/// <param name="QueryStateNonce">State value Google echoed back via the <c>state</c> query parameter.</param>
/// <param name="EncryptedStateCookie">Encrypted payload from the OAuth state cookie.</param>
public sealed record Command(
string Code,
string? QueryStateNonce,
string? EncryptedStateCookie) : IRequest<Result<Response>>;
/// <summary>Outcome surfaced to the redirect handler.</summary>
/// <param name="GoogleAccountId">Id of the persisted account row.</param>
/// <param name="DiscoveredCalendars">How many calendars were discovered (purely informational).</param>
public sealed record Response(Guid GoogleAccountId, int DiscoveredCalendars);
/// <summary>Handler — runs the OAuth post-back logic.</summary>
public sealed class Handler : IRequestHandler<Command, Result<Response>>
{
private readonly ApplicationDbContext _db;
private readonly GoogleOAuthService _oauth;
private readonly GoogleCalendarClient _calendarClient;
/// <summary>Primary constructor.</summary>
public Handler(
ApplicationDbContext db,
GoogleOAuthService oauth,
GoogleCalendarClient calendarClient)
{
_db = db;
_oauth = oauth;
_calendarClient = calendarClient;
}
/// <inheritdoc />
public async Task<Result<Response>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var state = _oauth.ValidateState(request.EncryptedStateCookie, request.QueryStateNonce);
if (state is null)
{
return ApplicationError.Validation(
"calendar.oauth_state_invalid",
"El estado OAuth es inválido o ha expirado. Inicia el proceso de vinculación de nuevo.");
}
var user = await _db.Users
.Include(u => u.FamilyMember)
.FirstOrDefaultAsync(u => u.Id == state.UserId, cancellationToken);
if (user is null)
{
return ApplicationError.NotFound(
"calendar.user_not_found",
"El usuario que inició la vinculación ya no existe.");
}
if (user.FamilyMember is null)
{
return ApplicationError.Forbidden(
"calendar.user_not_linked",
"El usuario debe estar enlazado a un miembro familiar para vincular Google Calendar.");
}
GoogleTokenResponse tokens;
try
{
tokens = await _oauth.ExchangeCodeAsync(request.Code, cancellationToken);
}
catch (Exception)
{
return ApplicationError.Validation(
"calendar.token_exchange_failed",
"Google rechazó el intercambio de código. Inténtalo de nuevo.");
}
if (string.IsNullOrEmpty(tokens.RefreshToken) || string.IsNullOrEmpty(tokens.IdToken))
{
return ApplicationError.Validation(
"calendar.missing_refresh_token",
"Google no devolvió un refresh token. Revoca el acceso en tu cuenta y vuelve a intentarlo.");
}
(string Email, string? Name) identity;
try
{
identity = GoogleOAuthService.DecodeIdToken(tokens.IdToken);
}
catch (Exception)
{
return ApplicationError.Validation(
"calendar.invalid_id_token",
"Google devolvió un id_token con formato inesperado.");
}
// Either reuse an existing link (refresh token rotation) or insert a new one.
var account = await _db.GoogleAccounts
.Include(a => a.Calendars)
.FirstOrDefaultAsync(a => a.UserId == user.Id && a.Email == identity.Email, cancellationToken);
if (account is null)
{
account = new GoogleAccount
{
FamilyId = user.FamilyMember.FamilyId,
UserId = user.Id,
Email = identity.Email,
DisplayName = identity.Name,
EncryptedRefreshToken = _oauth.ProtectRefreshToken(tokens.RefreshToken),
IsRevoked = false,
LastError = null,
};
_db.GoogleAccounts.Add(account);
}
else
{
account.EncryptedRefreshToken = _oauth.ProtectRefreshToken(tokens.RefreshToken);
account.DisplayName = identity.Name;
account.IsRevoked = false;
account.LastError = null;
}
// Discover calendars now so the cuentas UI has something to render right
// after the redirect. New calendars are added; pre-existing ones keep their
// IsImported / FamilyMemberId / SyncToken state.
IReadOnlyList<Google.Apis.Calendar.v3.Data.CalendarListEntry> discovered;
try
{
discovered = await _calendarClient.ListCalendarsAsync(tokens.RefreshToken, cancellationToken);
}
catch (Exception ex)
{
account.LastError = $"calendarList.list failed: {ex.Message}";
await _db.SaveChangesAsync(cancellationToken);
return ApplicationError.Validation(
"calendar.list_failed",
"No se pudo recuperar la lista de calendarios. Revisa los permisos otorgados a la app.");
}
var existingByExternalId = account.Calendars
.ToDictionary(c => c.ExternalCalendarId, StringComparer.Ordinal);
foreach (var entry in discovered)
{
if (string.IsNullOrEmpty(entry.Id))
{
continue;
}
if (existingByExternalId.TryGetValue(entry.Id, out var existing))
{
existing.Summary = entry.Summary ?? existing.Summary;
existing.Description = entry.Description ?? existing.Description;
existing.ColorHex = entry.BackgroundColor ?? existing.ColorHex;
}
else
{
account.Calendars.Add(new LinkedCalendar
{
GoogleAccountId = account.Id,
ExternalCalendarId = entry.Id,
Summary = entry.Summary ?? entry.Id,
Description = entry.Description,
ColorHex = entry.BackgroundColor,
IsImported = false,
});
}
}
await _db.SaveChangesAsync(cancellationToken);
return new Response(account.Id, account.Calendars.Count);
}
}
}

View file

@ -0,0 +1,92 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Slice: read events for the family calendar within a time window. Filters cover
/// the day/week/month/agenda views by adjusting <c>from</c>/<c>to</c> on the client side.
/// </summary>
public static class ListEvents
{
/// <summary>Query — half-open time window, optional member filter.</summary>
/// <param name="From">Inclusive lower bound (UTC).</param>
/// <param name="To">Exclusive upper bound (UTC).</param>
/// <param name="MemberIds">Optional set of family member ids; null/empty means "all members".</param>
public sealed record Query(
DateTimeOffset From,
DateTimeOffset To,
IReadOnlyList<Guid>? MemberIds) : IRequest<Result<IReadOnlyList<CalendarEventDto>>>;
/// <summary>Input validation.</summary>
public sealed class Validator : AbstractValidator<Query>
{
/// <summary>Creates the validator.</summary>
public Validator()
{
RuleFor(x => x.To).GreaterThan(x => x.From);
RuleFor(x => x.To.Subtract(x.From))
.LessThanOrEqualTo(TimeSpan.FromDays(370))
.WithMessage("Time window cannot exceed 370 days.");
}
}
/// <summary>Handler — runs the family-scoped range query.</summary>
public sealed class Handler : IRequestHandler<Query, Result<IReadOnlyList<CalendarEventDto>>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<IReadOnlyList<CalendarEventDto>>> HandleAsync(Query request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
}
// Standard "overlap" predicate: events where start < to AND end > from.
var events = _db.CalendarEvents
.AsNoTracking()
.Include(e => e.LinkedCalendar)
.Include(e => e.RelatedMembers)
.Where(e => e.FamilyId == current.Family.Id)
.Where(e => e.StartAt < request.To && e.EndAt > request.From);
if (request.MemberIds is { Count: > 0 } members)
{
var memberSet = members.ToHashSet();
// OR across two paths: the event surfaces on the per-member view
// either because its source calendar is bound to the member
// (RF-CAL-007) or because it has been explicitly tagged with
// them at the event level (RF-CAL-013).
events = events.Where(e =>
(e.LinkedCalendar!.FamilyMemberId.HasValue
&& memberSet.Contains(e.LinkedCalendar.FamilyMemberId.Value))
|| e.RelatedMembers.Any(m => memberSet.Contains(m.Id)));
}
var rows = await events
.OrderBy(e => e.StartAt)
.ThenBy(e => e.Title)
.ToListAsync(cancellationToken);
IReadOnlyList<CalendarEventDto> dto = [.. rows.Select(CalendarEventDto.From)];
return Result<IReadOnlyList<CalendarEventDto>>.Success(dto);
}
}
}

View file

@ -0,0 +1,49 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>Slice: list every Google account linked under the current family.</summary>
public static class ListLinkedAccounts
{
/// <summary>Empty query.</summary>
public sealed record Query() : IRequest<Result<IReadOnlyList<GoogleAccountDto>>>;
/// <summary>Handler — returns all family accounts (every adult sees the family-wide picture).</summary>
public sealed class Handler : IRequestHandler<Query, Result<IReadOnlyList<GoogleAccountDto>>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<IReadOnlyList<GoogleAccountDto>>> HandleAsync(Query request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
}
var accounts = await _db.GoogleAccounts
.AsNoTracking()
.Where(a => a.FamilyId == current.Family.Id)
.Include(a => a.Calendars)
.OrderBy(a => a.Email)
.ToListAsync(cancellationToken);
IReadOnlyList<GoogleAccountDto> dto = [.. accounts.Select(GoogleAccountDto.From)];
return Result<IReadOnlyList<GoogleAccountDto>>.Success(dto);
}
}
}

View file

@ -0,0 +1,96 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Slice: replace the set of family members tagged on a calendar event. The
/// API is set-based (not add/remove) because the editor in the UI shows the
/// full picker and posts the selection in one call — easier to reason about
/// than incremental diffs (RF-CAL-013).
/// </summary>
public static class SetCalendarEventMembers
{
/// <summary>Command.</summary>
/// <param name="EventId">Local event id (FamilyNido <c>CalendarEvent.Id</c>).</param>
/// <param name="MemberIds">New set of related members. Empty clears the relation.</param>
public sealed record Command(Guid EventId, IReadOnlyList<Guid> MemberIds)
: IRequest<Result<CalendarEventDto>>;
/// <summary>Validator.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator.</summary>
public Validator()
{
RuleFor(x => x.EventId).NotEmpty();
RuleFor(x => x.MemberIds).NotNull();
}
}
/// <summary>Handler.</summary>
public sealed class Handler : IRequestHandler<Command, Result<CalendarEventDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<CalendarEventDto>> HandleAsync(Command request, CancellationToken ct)
{
var current = await _userContext.GetAsync(ct);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var ev = await _db.CalendarEvents
.Include(e => e.LinkedCalendar)
.Include(e => e.RelatedMembers)
.FirstOrDefaultAsync(
e => e.Id == request.EventId && e.FamilyId == current.Family.Id,
ct);
if (ev is null)
{
return ApplicationError.NotFound("calendar_event.not_found", "Event not found.");
}
var newIds = request.MemberIds.ToHashSet();
var newMembers = newIds.Count == 0
? []
: await _db.FamilyMembers
.Where(m => m.FamilyId == current.Family.Id && newIds.Contains(m.Id))
.ToListAsync(ct);
if (newMembers.Count != newIds.Count)
{
return ApplicationError.Validation(
"calendar_event.unknown_member",
"One or more referenced members are not part of this family.");
}
// Replace the set in-memory; EF persists the join changes on save.
ev.RelatedMembers.Clear();
foreach (var m in newMembers)
{
ev.RelatedMembers.Add(m);
}
await _db.SaveChangesAsync(ct);
return CalendarEventDto.From(ev);
}
}
}

View file

@ -0,0 +1,50 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Slice: kick off the Google OAuth flow for the current user. Returns the URL the
/// frontend should redirect to plus the encrypted state payload that has to be set
/// in a short-lived cookie alongside the redirect.
/// </summary>
public static class StartGoogleLink
{
/// <summary>Empty command — the caller is identified via <see cref="ICurrentUserContext"/>.</summary>
public sealed record Command() : IRequest<Result<Response>>;
/// <summary>Response returned to the frontend.</summary>
/// <param name="AuthUrl">Full Google authorization URL the user has to follow.</param>
/// <param name="EncryptedState">Encrypted state to set in the OAuth state cookie.</param>
/// <param name="ExpiresAt">UTC instant the state expires (cookie max-age cap).</param>
public sealed record Response(string AuthUrl, string EncryptedState, DateTimeOffset ExpiresAt);
/// <summary>Handler — composes the auth URL via <see cref="GoogleOAuthService"/>.</summary>
public sealed class Handler : IRequestHandler<Command, Result<Response>>
{
private readonly ICurrentUserContext _userContext;
private readonly GoogleOAuthService _oauth;
/// <summary>Primary constructor.</summary>
public Handler(ICurrentUserContext userContext, GoogleOAuthService oauth)
{
_userContext = userContext;
_oauth = oauth;
}
/// <inheritdoc />
public async Task<Result<Response>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
}
var (authUrl, encryptedState, expiresAt) = _oauth.BuildAuthorizationRequest(current.User.Id);
return new Response(authUrl, encryptedState, expiresAt);
}
}
}

View file

@ -0,0 +1,67 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>Slice: trigger an inline sync of a single Google account on demand (RF-CAL-005).</summary>
public static class TriggerManualSync
{
/// <summary>Command — identifies the account to sync.</summary>
/// <param name="GoogleAccountId">Id of the account to sync.</param>
public sealed record Command(Guid GoogleAccountId) : IRequest<Result<GoogleAccountDto>>;
/// <summary>Handler — runs the synchronizer, then returns the updated DTO.</summary>
public sealed class Handler : IRequestHandler<Command, Result<GoogleAccountDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
private readonly CalendarSynchronizer _synchronizer;
/// <summary>Primary constructor.</summary>
public Handler(
ApplicationDbContext db,
ICurrentUserContext userContext,
CalendarSynchronizer synchronizer)
{
_db = db;
_userContext = userContext;
_synchronizer = synchronizer;
}
/// <inheritdoc />
public async Task<Result<GoogleAccountDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
}
var account = await _db.GoogleAccounts
.FirstOrDefaultAsync(
a => a.Id == request.GoogleAccountId && a.FamilyId == current.Family.Id,
cancellationToken);
if (account is null)
{
return ApplicationError.NotFound(
"calendar.account_not_found",
"La cuenta vinculada no existe o no pertenece a tu familia.");
}
await _synchronizer.SyncAccountAsync(account.Id, cancellationToken);
// Re-load with calendars attached to surface fresh LastSyncedAt values.
var refreshed = await _db.GoogleAccounts
.AsNoTracking()
.Include(a => a.Calendars)
.FirstAsync(a => a.Id == account.Id, cancellationToken);
return GoogleAccountDto.From(refreshed);
}
}
}

View file

@ -0,0 +1,72 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Slice: drop a linked Google account along with its calendars and cached events.
/// The owning user (or an admin) is the only one allowed to unlink — otherwise any
/// adult could wipe another user's integration.
/// </summary>
public static class UnlinkGoogleAccount
{
/// <summary>Command — identifies the account to unlink.</summary>
/// <param name="GoogleAccountId">Id of the account row.</param>
public sealed record Command(Guid GoogleAccountId) : IRequest<Result<Unit>>;
/// <summary>Carries no value; signals "completed" to the endpoint.</summary>
public sealed record Unit;
/// <summary>Handler — deletes the row; cascade removes calendars and events.</summary>
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
}
var account = await _db.GoogleAccounts
.FirstOrDefaultAsync(
a => a.Id == request.GoogleAccountId && a.FamilyId == current.Family.Id,
cancellationToken);
if (account is null)
{
return ApplicationError.NotFound(
"calendar.account_not_found",
"La cuenta vinculada no existe o no pertenece a tu familia.");
}
var isOwner = account.UserId == current.User.Id;
var isAdmin = current.User.Role == Domain.Families.FamilyRole.Admin;
if (!isOwner && !isAdmin)
{
return ApplicationError.Forbidden(
"calendar.account_forbidden",
"Solo el dueño de la cuenta o un admin puede desvincularla.");
}
_db.GoogleAccounts.Remove(account);
await _db.SaveChangesAsync(cancellationToken);
return new Unit();
}
}
}

View file

@ -0,0 +1,104 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Calendar;
/// <summary>
/// Slice: toggle the import flag and/or assign a family member to a calendar. Toggling
/// import off purges the cached events immediately so the UI does not keep stale rows.
/// </summary>
public static class UpdateLinkedCalendar
{
/// <summary>Command — partial update of a linked calendar.</summary>
/// <param name="LinkedCalendarId">Id of the linked calendar to update.</param>
/// <param name="IsImported">New value for the import flag.</param>
/// <param name="FamilyMemberId">Optional family member to associate (null clears the assignment).</param>
public sealed record Command(
Guid LinkedCalendarId,
bool IsImported,
Guid? FamilyMemberId) : IRequest<Result<LinkedCalendarDto>>;
/// <summary>Input validation.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator.</summary>
public Validator()
{
RuleFor(x => x.LinkedCalendarId).NotEmpty();
}
}
/// <summary>Handler — applies the patch and saves.</summary>
public sealed class Handler : IRequestHandler<Command, Result<LinkedCalendarDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<LinkedCalendarDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "No authenticated caller.");
}
var calendar = await _db.LinkedCalendars
.Include(c => c.GoogleAccount)
.FirstOrDefaultAsync(
c => c.Id == request.LinkedCalendarId &&
c.GoogleAccount!.FamilyId == current.Family.Id,
cancellationToken);
if (calendar is null)
{
return ApplicationError.NotFound(
"calendar.linked_calendar_not_found",
"El calendario indicado no existe o no pertenece a tu familia.");
}
if (request.FamilyMemberId is { } memberId)
{
var memberExists = await _db.FamilyMembers
.AnyAsync(m => m.Id == memberId && m.FamilyId == current.Family.Id, cancellationToken);
if (!memberExists)
{
return ApplicationError.Validation(
"calendar.member_not_found",
"El miembro familiar indicado no existe.");
}
}
var willTurnOff = calendar.IsImported && !request.IsImported;
calendar.IsImported = request.IsImported;
calendar.FamilyMemberId = request.FamilyMemberId;
// Toggling import off clears cached events and the sync token so a future
// toggle-on triggers a fresh full sync rather than a delta from a stale cursor.
if (willTurnOff)
{
await _db.CalendarEvents
.Where(e => e.LinkedCalendarId == calendar.Id)
.ExecuteDeleteAsync(cancellationToken);
calendar.SyncToken = null;
calendar.LastSyncedAt = null;
}
await _db.SaveChangesAsync(cancellationToken);
return LinkedCalendarDto.From(calendar);
}
}
}

View file

@ -0,0 +1,41 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Http;
using FamilyNido.Api.Shared.Mediator;
using FluentValidation;
namespace FamilyNido.Api.Features.Dashboard;
/// <summary>Endpoints exposing the dashboard widget layout.</summary>
public static class DashboardEndpoints
{
/// <summary>Registers <c>GET/PUT /api/dashboard/preferences</c>.</summary>
public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/dashboard").WithTags("Dashboard");
group.MapGet("/preferences", GetPreferencesAsync)
.RequireAuthorization(Policies.AuthenticatedUser);
group.MapPut("/preferences", UpdatePreferencesAsync)
.RequireAuthorization(Policies.AuthenticatedUser);
return app;
}
private static async Task<IResult> GetPreferencesAsync(IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new GetMyDashboardPreferences.Query(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> UpdatePreferencesAsync(
UpdateMyDashboardPreferences.Command command,
IValidator<UpdateMyDashboardPreferences.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null) return validation;
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
}

View file

@ -0,0 +1,13 @@
namespace FamilyNido.Api.Features.Dashboard;
/// <summary>One widget entry in the user's dashboard layout.</summary>
/// <param name="Id">Stable widget identifier (see <see cref="DashboardWidgets"/>).</param>
/// <param name="Visible">True when the widget is rendered on the dashboard.</param>
public sealed record DashboardWidgetDto(string Id, bool Visible);
/// <summary>
/// Wire shape returned by <c>GET /api/dashboard/preferences</c>: the ordered
/// list of widget entries. Always includes every known widget so the frontend
/// renders a complete settings screen even when the user has never touched it.
/// </summary>
public sealed record DashboardPreferencesDto(IReadOnlyList<DashboardWidgetDto> Widgets);

View file

@ -0,0 +1,51 @@
namespace FamilyNido.Api.Features.Dashboard;
/// <summary>
/// Catalogue of widget identifiers known to the dashboard. Anything outside
/// this list is rejected at the application layer when the user submits a
/// new layout — the frontend mirrors the IDs verbatim.
/// </summary>
/// <remarks>
/// Adding a widget is a three-step dance: extend this list (and document the
/// label), include it in <see cref="DefaultOrder"/>, and render it in the
/// dashboard component. New widgets default to visible at the bottom for
/// existing users.
/// </remarks>
public static class DashboardWidgets
{
/// <summary>Open-Meteo current-weather card.</summary>
public const string Weather = "weather";
/// <summary>Today's bus / extras / drop-off + pick-up summary.</summary>
public const string School = "school";
/// <summary>Pending household tasks for today.</summary>
public const string Tasks = "tasks";
/// <summary>Top of the upcoming Google Calendar events list.</summary>
public const string Calendar = "calendar";
/// <summary>Today's and tomorrow's planned meals.</summary>
public const string Meals = "meals";
/// <summary>Pinned wall messages.</summary>
public const string Wall = "wall";
/// <summary>Birthdays in the next 30 days.</summary>
public const string Birthdays = "birthdays";
/// <summary>Today's agenda — who is away from home and what for.</summary>
public const string Agenda = "agenda";
/// <summary>Family scoreboard — points earned this week from completed tasks.</summary>
public const string Scores = "scores";
/// <summary>Default order applied when a user has no row yet.</summary>
public static IReadOnlyList<string> DefaultOrder => new[]
{
Weather, School, Agenda, Tasks, Calendar, Meals, Wall, Scores, Birthdays,
};
/// <summary>True when <paramref name="id"/> is a known widget id.</summary>
public static bool IsKnown(string id) => DefaultOrder.Contains(id);
}

View file

@ -0,0 +1,102 @@
using System.Text.Json;
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Dashboard;
/// <summary>
/// Slice for <c>GET /api/dashboard/preferences</c>. Reconciles the persisted
/// JSON with the catalogue of known widgets so:
/// <list type="bullet">
/// <item>Widgets the user explicitly hid stay hidden.</item>
/// <item>Widgets removed from the catalogue silently disappear.</item>
/// <item>New widgets added to the catalogue land at the end, visible by default.</item>
/// </list>
/// The result is the layout the frontend renders straight away.
/// </summary>
public static class GetMyDashboardPreferences
{
/// <summary>Query carries no payload — the caller is the implicit subject.</summary>
public sealed record Query : IRequest<Result<DashboardPreferencesDto>>;
/// <summary>Reads + reconciles the row.</summary>
public sealed class Handler : IRequestHandler<Query, Result<DashboardPreferencesDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<DashboardPreferencesDto>> HandleAsync(Query request, CancellationToken cancellationToken)
{
var user = await _userContext.GetUserAsync(cancellationToken);
if (user is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "Not signed in.");
}
var stored = await _db.UserDashboardPreferences
.AsNoTracking()
.Where(p => p.UserId == user.Id)
.Select(p => p.WidgetsJson)
.FirstOrDefaultAsync(cancellationToken);
return new DashboardPreferencesDto(Reconcile(stored));
}
/// <summary>
/// Build the effective widget list: persisted entries are kept in their
/// stored order (filtering out IDs no longer in the catalogue), and any
/// new catalogue entry is appended at the end with <c>Visible=true</c>.
/// </summary>
internal static IReadOnlyList<DashboardWidgetDto> Reconcile(string? widgetsJson)
{
var stored = ParseStored(widgetsJson);
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<DashboardWidgetDto>();
foreach (var entry in stored)
{
if (!DashboardWidgets.IsKnown(entry.Id) || !seen.Add(entry.Id)) continue;
result.Add(entry);
}
foreach (var id in DashboardWidgets.DefaultOrder)
{
if (seen.Add(id))
{
result.Add(new DashboardWidgetDto(id, Visible: true));
}
}
return result;
}
private static IReadOnlyList<DashboardWidgetDto> ParseStored(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try
{
var parsed = JsonSerializer.Deserialize<List<DashboardWidgetDto>>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return parsed ?? [];
}
catch (JsonException)
{
// Corrupted column — treat as defaults rather than failing the whole API.
return [];
}
}
}
}

View file

@ -0,0 +1,83 @@
using System.Text.Json;
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Notifications;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Dashboard;
/// <summary>
/// Slice for <c>PUT /api/dashboard/preferences</c>. Replaces the user's widget
/// layout with the supplied list. The persisted JSON is then re-reconciled on
/// read, so unknown ids the caller may slip in are simply ignored next time.
/// </summary>
public static class UpdateMyDashboardPreferences
{
/// <summary>Command carrying the full ordered widget list.</summary>
public sealed record Command(IReadOnlyList<DashboardWidgetDto> Widgets)
: IRequest<Result<DashboardPreferencesDto>>;
/// <summary>Validation: every id must be from the catalogue and unique.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator with all rules registered.</summary>
public Validator()
{
RuleFor(x => x.Widgets).NotNull();
RuleForEach(x => x.Widgets)
.Must(w => DashboardWidgets.IsKnown(w.Id))
.WithMessage("Unknown widget id.");
RuleFor(x => x.Widgets)
.Must(list => list.Select(w => w.Id).Distinct().Count() == list.Count)
.WithMessage("Duplicate widget id.");
}
}
/// <summary>Performs the upsert.</summary>
public sealed class Handler : IRequestHandler<Command, Result<DashboardPreferencesDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<DashboardPreferencesDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var user = await _userContext.GetUserAsync(cancellationToken);
if (user is null)
{
return ApplicationError.Forbidden("auth.unauthenticated", "Not signed in.");
}
// Serialise the requested layout — Reconcile() will trim unknowns
// on the next read, so what we persist is exactly what we accept.
var json = JsonSerializer.Serialize(request.Widgets);
var prefs = await _db.UserDashboardPreferences
.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
if (prefs is null)
{
prefs = new UserDashboardPreferences { UserId = user.Id, WidgetsJson = json };
_db.UserDashboardPreferences.Add(prefs);
}
else
{
prefs.WidgetsJson = json;
}
await _db.SaveChangesAsync(cancellationToken);
return new DashboardPreferencesDto(GetMyDashboardPreferences.Handler.Reconcile(json));
}
}
}

View file

@ -0,0 +1,18 @@
namespace FamilyNido.Api.Features.Families;
/// <summary>Wire shape returned by the family settings endpoint.</summary>
/// <param name="Id">Family id.</param>
/// <param name="Name">Display name shown in the header.</param>
/// <param name="TimeZone">IANA timezone (e.g. "Europe/Madrid").</param>
/// <param name="Locale">BCP-47 locale used for default formatting.</param>
/// <param name="Latitude">Geographic latitude in decimal degrees, or null when not configured.</param>
/// <param name="Longitude">Geographic longitude paired with latitude.</param>
/// <param name="LocationLabel">Human-readable location label (city/town/village).</param>
public sealed record FamilyDto(
Guid Id,
string Name,
string TimeZone,
string Locale,
double? Latitude,
double? Longitude,
string? LocationLabel);

View file

@ -0,0 +1,43 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Http;
using FamilyNido.Api.Shared.Mediator;
using FluentValidation;
namespace FamilyNido.Api.Features.Families;
/// <summary>Endpoints exposing the family profile (read for everyone, mutate for admins).</summary>
public static class FamilyEndpoints
{
/// <summary>Registers <c>GET /api/family</c> and <c>PUT /api/family/location</c>.</summary>
public static IEndpointRouteBuilder MapFamilyEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/family").WithTags("Family");
group.MapGet("/", GetAsync).RequireAuthorization(Policies.AuthenticatedUser);
group.MapPut("/location", UpdateLocationAsync).RequireAuthorization(Policies.AuthenticatedUser);
return app;
}
private static async Task<IResult> GetAsync(IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new GetMyFamily.Query(), ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> UpdateLocationAsync(
UpdateFamilyLocation.Command command,
IValidator<UpdateFamilyLocation.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null)
{
return validation;
}
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
}

View file

@ -0,0 +1,38 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
namespace FamilyNido.Api.Features.Families;
/// <summary>
/// Slice for <c>GET /api/family</c>. Returns the caller's family profile —
/// any authenticated member of the family can read it.
/// </summary>
public static class GetMyFamily
{
/// <summary>Query carries no payload; the caller resolves the family.</summary>
public sealed record Query : IRequest<Result<FamilyDto>>;
/// <summary>Resolves the family through <see cref="ICurrentUserContext"/>.</summary>
public sealed class Handler : IRequestHandler<Query, Result<FamilyDto>>
{
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ICurrentUserContext userContext) => _userContext = userContext;
/// <inheritdoc />
public async Task<Result<FamilyDto>> HandleAsync(Query request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family.");
}
var f = current.Family;
return new FamilyDto(f.Id, f.Name, f.TimeZone, f.Locale, f.Latitude, f.Longitude, f.LocationLabel);
}
}
}

View file

@ -0,0 +1,95 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Families;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Families;
/// <summary>
/// Slice for <c>PUT /api/family/location</c>. Lets a family admin set or
/// clear the family's geographic location. Drives the dashboard weather
/// widget — without coordinates, the widget hides itself.
/// </summary>
public static class UpdateFamilyLocation
{
/// <summary>Command carrying the new location, or all nulls to clear it.</summary>
/// <param name="Latitude">Decimal degrees in [-90, 90], or null to clear.</param>
/// <param name="Longitude">Decimal degrees in [-180, 180], or null to clear.</param>
/// <param name="LocationLabel">Optional human-readable label (city/town/village).</param>
public sealed record Command(
double? Latitude,
double? Longitude,
string? LocationLabel) : IRequest<Result<FamilyDto>>;
/// <summary>Validation: lat/lon must be both null or both within range.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator with all rules registered.</summary>
public Validator()
{
RuleFor(x => x.Latitude)
.InclusiveBetween(-90, 90)
.When(x => x.Latitude is not null);
RuleFor(x => x.Longitude)
.InclusiveBetween(-180, 180)
.When(x => x.Longitude is not null);
RuleFor(x => x)
.Must(c => (c.Latitude is null) == (c.Longitude is null))
.WithMessage("Latitude and Longitude must be set or cleared together.");
RuleFor(x => x.LocationLabel).MaximumLength(120);
}
}
/// <summary>Persists the new location on the family row.</summary>
public sealed class Handler : IRequestHandler<Command, Result<FamilyDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<FamilyDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family.");
}
if (current.User.Role != FamilyRole.Admin)
{
return ApplicationError.Forbidden("family.location.admin_only", "Only family admins can change the location.");
}
var family = await _db.Families.FirstAsync(f => f.Id == current.Family.Id, cancellationToken);
family.Latitude = request.Latitude;
family.Longitude = request.Longitude;
family.LocationLabel = string.IsNullOrWhiteSpace(request.LocationLabel) ? null : request.LocationLabel.Trim();
await _db.SaveChangesAsync(cancellationToken);
return new FamilyDto(
family.Id,
family.Name,
family.TimeZone,
family.Locale,
family.Latitude,
family.Longitude,
family.LocationLabel);
}
}
}

View file

@ -0,0 +1,57 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>Slice: re-activate a previously archived member (RF-USR-004).</summary>
public static class ActivateFamilyMember
{
/// <summary>Command carrying the target id.</summary>
public sealed record Command(Guid MemberId) : IRequest<Result<FamilyMemberDto>>;
/// <summary>Handler — flips <c>IsActive</c> to true.</summary>
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var member = await _db.FamilyMembers
.Include(m => m.User)
.FirstOrDefaultAsync(
m => m.Id == request.MemberId && m.FamilyId == current.Family.Id,
cancellationToken);
if (member is null)
{
return ApplicationError.NotFound(
"family_member.not_found",
$"Member {request.MemberId} not found in family.");
}
member.IsActive = true;
await _db.SaveChangesAsync(cancellationToken);
return FamilyMemberDto.From(member);
}
}
}

View file

@ -0,0 +1,102 @@
using System.Text.RegularExpressions;
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Families;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>Slice: create a new <see cref="FamilyMember"/> under the caller's family (RF-USR-002).</summary>
public static partial class CreateFamilyMember
{
[GeneratedRegex("^#[0-9a-fA-F]{6}$", RegexOptions.Compiled)]
private static partial Regex HexColorRegex();
/// <summary>Command carrying the input payload.</summary>
/// <param name="DisplayName">Name shown in the UI. 1-120 chars.</param>
/// <param name="MemberType">Kind of member.</param>
/// <param name="ColorHex">Hex color (#RRGGBB) to use consistently across the app.</param>
/// <param name="BirthDate">Optional date of birth.</param>
/// <param name="ContactEmail">Optional informational contact email.</param>
public sealed record Command(
string DisplayName,
MemberType MemberType,
string ColorHex,
DateOnly? BirthDate,
string? ContactEmail) : IRequest<Result<FamilyMemberDto>>;
/// <summary>Input validation.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator with all rules registered.</summary>
public Validator()
{
RuleFor(x => x.DisplayName)
.NotEmpty()
.MaximumLength(120);
RuleFor(x => x.ColorHex)
.NotEmpty()
.Matches(HexColorRegex())
.WithMessage("ColorHex must be in #RRGGBB format.");
RuleFor(x => x.ContactEmail)
.EmailAddress()
.MaximumLength(254)
.When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
RuleFor(x => x.BirthDate)
.Must(d => d is null || d.Value <= DateOnly.FromDateTime(DateTime.UtcNow))
.WithMessage("BirthDate cannot be in the future.");
}
}
/// <summary>Handler — persists a new family member row.</summary>
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var familyExists = await _db.Families.AnyAsync(f => f.Id == current.Family.Id, cancellationToken);
if (!familyExists)
{
return ApplicationError.NotFound("family.not_found", $"Family {current.Family.Id} does not exist.");
}
var member = new FamilyMember
{
FamilyId = current.Family.Id,
DisplayName = request.DisplayName,
MemberType = request.MemberType,
ColorHex = request.ColorHex,
BirthDate = request.BirthDate,
ContactEmail = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail,
};
_db.FamilyMembers.Add(member);
await _db.SaveChangesAsync(cancellationToken);
return FamilyMemberDto.From(member);
}
}
}

View file

@ -0,0 +1,91 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Families;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>Slice: soft-deactivate a member while preserving history (RF-USR-004).</summary>
public static class DeactivateFamilyMember
{
/// <summary>Command carrying the target id.</summary>
public sealed record Command(Guid MemberId) : IRequest<Result<FamilyMemberDto>>;
/// <summary>Handler — enforces the at-least-one-admin invariant.</summary>
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var member = await _db.FamilyMembers
.Include(m => m.User)
.FirstOrDefaultAsync(
m => m.Id == request.MemberId && m.FamilyId == current.Family.Id,
cancellationToken);
if (member is null)
{
return ApplicationError.NotFound(
"family_member.not_found",
$"Member {request.MemberId} not found in family.");
}
if (await WouldLeaveZeroAdminsAsync(_db, member, cancellationToken))
{
return ApplicationError.Conflict(
"family.must_keep_admin",
"Cannot deactivate the last admin of the family.");
}
member.IsActive = false;
await _db.SaveChangesAsync(cancellationToken);
return FamilyMemberDto.From(member);
}
}
/// <summary>
/// True when <paramref name="member"/> is an active admin AND there are no
/// other active admins in the family. Shared with <see cref="DeleteFamilyMember"/>.
/// </summary>
internal static async Task<bool> WouldLeaveZeroAdminsAsync(
ApplicationDbContext db,
FamilyMember member,
CancellationToken ct)
{
if (member.User is null || member.User.Role != FamilyRole.Admin)
{
return false;
}
var otherActiveAdmins = await db.FamilyMembers
.CountAsync(m =>
m.FamilyId == member.FamilyId
&& m.Id != member.Id
&& m.IsActive
&& m.User != null
&& m.User.Role == FamilyRole.Admin,
ct);
return otherActiveAdmins == 0;
}
}

View file

@ -0,0 +1,64 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>Slice: hard-delete a member, preserving the at-least-one-admin invariant (RF-USR-004).</summary>
public static class DeleteFamilyMember
{
/// <summary>Command carrying the target id.</summary>
public sealed record Command(Guid MemberId) : IRequest<Result<Unit>>;
/// <summary>Handler — refuses if the target is the last admin.</summary>
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var member = await _db.FamilyMembers
.Include(m => m.User)
.FirstOrDefaultAsync(
m => m.Id == request.MemberId && m.FamilyId == current.Family.Id,
cancellationToken);
if (member is null)
{
return ApplicationError.NotFound(
"family_member.not_found",
$"Member {request.MemberId} not found in family.");
}
if (await DeactivateFamilyMember.WouldLeaveZeroAdminsAsync(_db, member, cancellationToken))
{
return ApplicationError.Conflict(
"family.must_keep_admin",
"Cannot delete the last admin of the family.");
}
_db.FamilyMembers.Remove(member);
await _db.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
}

View file

@ -0,0 +1,47 @@
using FamilyNido.Api.Features.Invitations;
using FamilyNido.Domain.Families;
using FamilyNido.Domain.Identity;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>
/// Read-model projection of a <see cref="FamilyMember"/> returned by the API.
/// </summary>
/// <param name="Id">Stable member identifier.</param>
/// <param name="DisplayName">Name shown across the UI.</param>
/// <param name="MemberType">Kind of member (adult/child/other).</param>
/// <param name="ColorHex">Consistent color for this member in calendar and avatars.</param>
/// <param name="BirthDate">Date of birth if known.</param>
/// <param name="ContactEmail">Informational contact email, not the OIDC email.</param>
/// <param name="PhotoPath">Relative path to the avatar file, if any.</param>
/// <param name="IsActive">Whether the member is active or archived.</param>
/// <param name="HasAccount">True when a user account is linked.</param>
/// <param name="Role">Authorization role if the member has an account, else null.</param>
/// <param name="PendingInvitation">Pending (non-consumed, non-revoked, non-expired) invitation, if any.</param>
public sealed record FamilyMemberDto(
Guid Id,
string DisplayName,
MemberType MemberType,
string ColorHex,
DateOnly? BirthDate,
string? ContactEmail,
string? PhotoPath,
bool IsActive,
bool HasAccount,
FamilyRole? Role,
PendingInvitationDto? PendingInvitation)
{
/// <summary>Project a domain entity to the DTO shape used by the API.</summary>
public static FamilyMemberDto From(FamilyMember m, Invitation? pending = null) => new(
Id: m.Id,
DisplayName: m.DisplayName,
MemberType: m.MemberType,
ColorHex: m.ColorHex,
BirthDate: m.BirthDate,
ContactEmail: m.ContactEmail,
PhotoPath: m.PhotoPath,
IsActive: m.IsActive,
HasAccount: m.UserId is not null,
Role: m.User?.Role,
PendingInvitation: pending is null ? null : new PendingInvitationDto(pending.Id, pending.Email, pending.ExpiresAt));
}

View file

@ -0,0 +1,214 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Options;
using FamilyNido.Api.Shared.Http;
using FamilyNido.Api.Shared.Mediator;
using FluentValidation;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>REST endpoints for managing family members (RF-USR-*).</summary>
public static class FamilyMemberEndpoints
{
/// <summary>Registers <c>/api/family-members</c> endpoints on the given route group.</summary>
public static IEndpointRouteBuilder MapFamilyMemberEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/family-members")
.WithTags("FamilyMembers")
.RequireAuthorization(Policies.AuthenticatedUser);
group.MapGet("/", ListAsync);
group.MapGet("/{id:guid}", GetAsync);
group.MapPost("/", CreateAsync).RequireAuthorization(Policies.Admin);
group.MapPut("/{id:guid}", UpdateAsync).RequireAuthorization(Policies.Admin);
group.MapPatch("/{id:guid}/deactivate", DeactivateAsync).RequireAuthorization(Policies.Admin);
group.MapPatch("/{id:guid}/activate", ActivateAsync).RequireAuthorization(Policies.Admin);
group.MapDelete("/{id:guid}", DeleteAsync).RequireAuthorization(Policies.Admin);
// Profile photo: any authenticated user can read the bytes; the
// upload/delete handlers run their own admin-OR-self check inside.
group.MapGet("/{id:guid}/photo", DownloadPhotoAsync);
group.MapPost("/{id:guid}/photo", UploadPhotoAsync).DisableAntiforgery();
group.MapDelete("/{id:guid}/photo", RemovePhotoAsync);
return app;
}
private static async Task<IResult> ListAsync(
bool? includeInactive,
IMediator mediator,
CancellationToken ct)
{
var result = await mediator.SendAsync(new ListFamilyMembers.Query(includeInactive ?? false), ct);
return result.IsSuccess
? Results.Ok(result.Value)
: result.Error.ToHttpResult();
}
private static async Task<IResult> GetAsync(
Guid id,
IMediator mediator,
CancellationToken ct)
{
var result = await mediator.SendAsync(new GetFamilyMember.Query(id), ct);
return result.IsSuccess
? Results.Ok(result.Value)
: result.Error.ToHttpResult();
}
private static async Task<IResult> CreateAsync(
CreateFamilyMember.Command command,
IValidator<CreateFamilyMember.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null)
{
return validation;
}
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess
? Results.Created($"/api/family-members/{result.Value.Id}", result.Value)
: result.Error.ToHttpResult();
}
private static async Task<IResult> UpdateAsync(
Guid id,
UpdateFamilyMemberBody body,
IValidator<UpdateFamilyMember.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var command = new UpdateFamilyMember.Command(id, body.DisplayName, body.ColorHex, body.BirthDate, body.ContactEmail);
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null)
{
return validation;
}
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess
? Results.Ok(result.Value)
: result.Error.ToHttpResult();
}
private static async Task<IResult> ActivateAsync(Guid id, IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new ActivateFamilyMember.Command(id), ct);
return result.IsSuccess
? Results.Ok(result.Value)
: result.Error.ToHttpResult();
}
private static async Task<IResult> DeactivateAsync(Guid id, IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new DeactivateFamilyMember.Command(id), ct);
return result.IsSuccess
? Results.Ok(result.Value)
: result.Error.ToHttpResult();
}
private static async Task<IResult> DeleteAsync(Guid id, IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new DeleteFamilyMember.Command(id), ct);
return result.IsSuccess
? Results.NoContent()
: result.Error.ToHttpResult();
}
private static async Task<IResult> UploadPhotoAsync(
Guid id,
HttpRequest request,
IMediator mediator,
CancellationToken ct)
{
if (!request.HasFormContentType)
{
return Results.BadRequest(new { code = "photo.missing_multipart", message = "Upload must be multipart/form-data." });
}
var form = await request.ReadFormAsync(ct);
var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault();
if (file is null || file.Length == 0)
{
return Results.BadRequest(new { code = "photo.missing", message = "No file provided." });
}
try
{
await using var stream = file.OpenReadStream();
var command = new UploadMemberPhoto.Command(id, stream, file.ContentType, file.Length);
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
catch (SixLabors.ImageSharp.UnknownImageFormatException)
{
return Results.BadRequest(new { code = "photo.unsupported_type", message = "File could not be decoded as a supported image." });
}
catch (SixLabors.ImageSharp.InvalidImageContentException)
{
return Results.BadRequest(new { code = "photo.corrupt", message = "Image data is malformed." });
}
}
private static async Task<IResult> RemovePhotoAsync(Guid id, IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new RemoveMemberPhoto.Command(id), ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> DownloadPhotoAsync(
Guid id,
IMediator mediator,
IOptions<FilesOptions> filesOptions,
CancellationToken ct)
{
// Resolve the row through the standard read slice so cross-family
// access is rejected (the slice already filters by the caller's
// family). We then stream the file from disk.
var result = await mediator.SendAsync(new GetFamilyMember.Query(id), ct);
if (!result.IsSuccess)
{
return result.Error.ToHttpResult();
}
var member = result.Value;
if (string.IsNullOrEmpty(member.PhotoPath))
{
return Results.NotFound();
}
// Resolve to an absolute path. Results.File interprets relative paths
// against the IWebHostEnvironment.WebRootFileProvider (i.e. wwwroot),
// which silently breaks when StorageRoot is configured as a relative
// path like "./data/uploads" — the default in Development.
var fullPath = Path.GetFullPath(
Path.Combine(filesOptions.Value.StorageRoot, member.PhotoPath));
if (!File.Exists(fullPath))
{
// The pointer survived but the file is gone (manual cleanup,
// volume rotation, …). Returning 404 keeps the avatar pipeline
// falling back to initials.
return Results.NotFound();
}
return Results.File(fullPath, contentType: "image/jpeg", enableRangeProcessing: true);
}
}
/// <summary>
/// Wire-level payload for PUT /api/family-members/{id}. The route parameter
/// carries the target id, so the body only carries the mutable fields.
/// </summary>
/// <param name="DisplayName">Name shown in the UI. 1-120 chars.</param>
/// <param name="ColorHex">Hex color (#RRGGBB).</param>
/// <param name="BirthDate">Optional date of birth.</param>
/// <param name="ContactEmail">Optional informational contact email.</param>
public sealed record UpdateFamilyMemberBody(
string DisplayName,
string ColorHex,
DateOnly? BirthDate,
string? ContactEmail);

View file

@ -0,0 +1,50 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>Fetches a single member of the caller's family by id (RF-USR-001).</summary>
public static class GetFamilyMember
{
/// <summary>Query carrying the target member id.</summary>
public sealed record Query(Guid MemberId) : IRequest<Result<FamilyMemberDto>>;
/// <summary>Handler. Resolves the calling family before querying.</summary>
public sealed class Handler : IRequestHandler<Query, Result<FamilyMemberDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<FamilyMemberDto>> HandleAsync(Query request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var member = await _db.FamilyMembers
.AsNoTracking()
.Include(m => m.User)
.FirstOrDefaultAsync(
m => m.Id == request.MemberId && m.FamilyId == current.Family.Id,
cancellationToken);
return member is null
? ApplicationError.NotFound("family_member.not_found", $"Member {request.MemberId} not found in family.")
: FamilyMemberDto.From(member);
}
}
}

View file

@ -0,0 +1,78 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>Lists members of the current user's family (RF-USR-001).</summary>
public static class ListFamilyMembers
{
/// <summary>Query carrying the optional "include archived" flag.</summary>
/// <param name="IncludeInactive">When true, archived rows are also returned.</param>
public sealed record Query(bool IncludeInactive) : IRequest<Result<IReadOnlyList<FamilyMemberDto>>>;
/// <summary>EF Core-backed handler. Requires the caller to be linked.</summary>
public sealed class Handler : IRequestHandler<Query, Result<IReadOnlyList<FamilyMemberDto>>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
private readonly TimeProvider _timeProvider;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext, TimeProvider timeProvider)
{
_db = db;
_userContext = userContext;
_timeProvider = timeProvider;
}
/// <inheritdoc />
public async Task<Result<IReadOnlyList<FamilyMemberDto>>> HandleAsync(
Query request,
CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var now = _timeProvider.GetUtcNow();
var query = _db.FamilyMembers
.AsNoTracking()
.Include(m => m.User)
.Where(m => m.FamilyId == current.Family.Id);
if (!request.IncludeInactive)
{
query = query.Where(m => m.IsActive);
}
// Project each member with its most-recent live invitation (if any)
// in a single SQL round-trip — avoids the N+1 we'd get if the front
// had to ask "is there a pending invitation?" per row.
var rows = await query
.OrderBy(m => m.MemberType)
.ThenBy(m => m.DisplayName)
.Select(m => new
{
Member = m,
Pending = _db.Invitations
.Where(i => i.FamilyMemberId == m.Id
&& i.ConsumedAt == null
&& i.RevokedAt == null
&& i.ExpiresAt > now)
.OrderByDescending(i => i.CreatedAt)
.FirstOrDefault(),
})
.ToListAsync(cancellationToken);
IReadOnlyList<FamilyMemberDto> dto = [.. rows.Select(r => FamilyMemberDto.From(r.Member, r.Pending))];
return Result<IReadOnlyList<FamilyMemberDto>>.Success(dto);
}
}
}

View file

@ -0,0 +1,93 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Options;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Families;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>
/// Slice: clears a member's photo. Mirrors the auth rules of
/// <see cref="UploadMemberPhoto"/> (admin OR self) and removes both the row
/// pointer (PhotoPath) and the underlying file on disk so the avatar
/// pipeline falls back to initials.
/// </summary>
public static class RemoveMemberPhoto
{
/// <summary>Command.</summary>
/// <param name="MemberId">Target member id.</param>
public sealed record Command(Guid MemberId) : IRequest<Result<FamilyMemberDto>>;
/// <summary>Handler.</summary>
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
private readonly FilesOptions _filesOptions;
/// <summary>Primary constructor.</summary>
public Handler(
ApplicationDbContext db,
ICurrentUserContext userContext,
IOptions<FilesOptions> filesOptions)
{
_db = db;
_userContext = userContext;
_filesOptions = filesOptions.Value;
}
/// <inheritdoc />
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken ct)
{
var current = await _userContext.GetAsync(ct);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var member = await _db.FamilyMembers
.Include(m => m.User)
.FirstOrDefaultAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, ct);
if (member is null)
{
return ApplicationError.NotFound("family_member.not_found", "Member not found.");
}
var isAdmin = current.User.Role == FamilyRole.Admin;
var isSelf = current.Member.Id == member.Id;
if (!isAdmin && !isSelf)
{
return ApplicationError.Forbidden(
"family_member.not_self_or_admin",
"Only an admin or the member themself can remove this photo.");
}
if (!string.IsNullOrEmpty(member.PhotoPath))
{
var fullPath = Path.Combine(_filesOptions.StorageRoot, member.PhotoPath);
// Best-effort delete: if the file is already gone (manual cleanup,
// volume rotated, …) we still clear the DB pointer.
try
{
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
}
catch
{
// Swallow — clearing the row matters more than removing the file.
}
}
member.PhotoPath = null;
await _db.SaveChangesAsync(ct);
return FamilyMemberDto.From(member);
}
}
}

View file

@ -0,0 +1,102 @@
using System.Text.RegularExpressions;
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>Slice: update mutable fields of an existing member (RF-USR-003).</summary>
public static partial class UpdateFamilyMember
{
[GeneratedRegex("^#[0-9a-fA-F]{6}$", RegexOptions.Compiled)]
private static partial Regex HexColorRegex();
/// <summary>Command carrying the target id and the new values.</summary>
/// <param name="MemberId">Target member id.</param>
/// <param name="DisplayName">Name shown in the UI. 1-120 chars.</param>
/// <param name="ColorHex">Hex color (#RRGGBB).</param>
/// <param name="BirthDate">Optional date of birth.</param>
/// <param name="ContactEmail">Optional informational contact email.</param>
public sealed record Command(
Guid MemberId,
string DisplayName,
string ColorHex,
DateOnly? BirthDate,
string? ContactEmail) : IRequest<Result<FamilyMemberDto>>;
/// <summary>Input validation.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator with all rules registered.</summary>
public Validator()
{
RuleFor(x => x.DisplayName)
.NotEmpty()
.MaximumLength(120);
RuleFor(x => x.ColorHex)
.NotEmpty()
.Matches(HexColorRegex())
.WithMessage("ColorHex must be in #RRGGBB format.");
RuleFor(x => x.ContactEmail)
.EmailAddress()
.MaximumLength(254)
.When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
RuleFor(x => x.BirthDate)
.Must(d => d is null || d.Value <= DateOnly.FromDateTime(DateTime.UtcNow))
.WithMessage("BirthDate cannot be in the future.");
}
}
/// <summary>Handler — writes the mutable fields to the tracked entity.</summary>
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var member = await _db.FamilyMembers
.Include(m => m.User)
.FirstOrDefaultAsync(
m => m.Id == request.MemberId && m.FamilyId == current.Family.Id,
cancellationToken);
if (member is null)
{
return ApplicationError.NotFound(
"family_member.not_found",
$"Member {request.MemberId} not found in family.");
}
member.DisplayName = request.DisplayName;
member.ColorHex = request.ColorHex;
member.BirthDate = request.BirthDate;
member.ContactEmail = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail;
await _db.SaveChangesAsync(cancellationToken);
return FamilyMemberDto.From(member);
}
}
}

View file

@ -0,0 +1,139 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Options;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Families;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace FamilyNido.Api.Features.FamilyMembers;
/// <summary>
/// Slice: a member's profile photo. The endpoint accepts any of the
/// images allowed by <see cref="FilesOptions"/>, decodes them with
/// ImageSharp, crops them to a 512×512 square and re-encodes as JPEG so
/// every avatar has a uniform shape and size on disk regardless of source.
/// Reused by both the admin (any member) and self (the linked user)
/// permissions paths.
/// </summary>
public static class UploadMemberPhoto
{
/// <summary>Final stored size for every avatar (square).</summary>
public const int OutputSize = 512;
/// <summary>JPEG quality for the re-encoded avatar.</summary>
private const int JpegQuality = 88;
/// <summary>Subfolder under the files volume where avatars live.</summary>
private const string AvatarsFolder = "members";
/// <summary>Command.</summary>
/// <param name="MemberId">Target member id.</param>
/// <param name="Stream">Input image stream (raw bytes from multipart).</param>
/// <param name="ContentType">Reported MIME type from the multipart part.</param>
/// <param name="LengthBytes">Reported byte count from the multipart part.</param>
public sealed record Command(
Guid MemberId,
Stream Stream,
string ContentType,
long LengthBytes) : IRequest<Result<FamilyMemberDto>>;
/// <summary>Handler.</summary>
public sealed class Handler : IRequestHandler<Command, Result<FamilyMemberDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
private readonly FilesOptions _filesOptions;
/// <summary>Primary constructor.</summary>
public Handler(
ApplicationDbContext db,
ICurrentUserContext userContext,
IOptions<FilesOptions> filesOptions)
{
_db = db;
_userContext = userContext;
_filesOptions = filesOptions.Value;
}
/// <inheritdoc />
public async Task<Result<FamilyMemberDto>> HandleAsync(Command request, CancellationToken ct)
{
var current = await _userContext.GetAsync(ct);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var member = await _db.FamilyMembers
.Include(m => m.User)
.FirstOrDefaultAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, ct);
if (member is null)
{
return ApplicationError.NotFound("family_member.not_found", "Member not found.");
}
// Authorization: the admin can change anyone's photo; a regular user
// can only change their *own* member's photo.
var isAdmin = current.User.Role == FamilyRole.Admin;
var isSelf = current.Member.Id == member.Id;
if (!isAdmin && !isSelf)
{
return ApplicationError.Forbidden(
"family_member.not_self_or_admin",
"Only an admin or the member themself can change this photo.");
}
// MIME + size guards. We trust the multipart-supplied content-type
// here (it's checked again by ImageSharp when decoding); a wrong
// type would also fail to decode and surface a validation error.
if (request.LengthBytes <= 0)
{
return ApplicationError.Validation("photo.empty", "Empty file.");
}
if (request.LengthBytes > _filesOptions.MaxImageBytes)
{
return ApplicationError.Validation("photo.too_large", "File exceeds the configured size limit.");
}
if (!_filesOptions.AllowedImageTypes.Contains(request.ContentType, StringComparer.OrdinalIgnoreCase))
{
return ApplicationError.Validation("photo.unsupported_type", $"Content type '{request.ContentType}' not supported.");
}
// Decode → resize+crop center → JPEG. ImageSharp throws on malformed
// input; the endpoint wrapper catches and surfaces a clean 400.
using var image = await Image.LoadAsync(request.Stream, ct);
image.Mutate(ctx => ctx.Resize(new ResizeOptions
{
Mode = ResizeMode.Crop,
Size = new Size(OutputSize, OutputSize),
Sampler = KnownResamplers.Lanczos3,
}));
// Persist as members/{memberId}.jpg, overwriting any previous photo
// so we never accumulate stale files. The path is deterministic so
// the static-file middleware can cache it (and busts via the entity
// tag when the bytes change).
var folder = Path.Combine(_filesOptions.StorageRoot, AvatarsFolder);
Directory.CreateDirectory(folder);
var fileName = $"{member.Id:N}.jpg";
var fullPath = Path.Combine(folder, fileName);
await using (var output = File.Create(fullPath))
{
var encoder = new JpegEncoder { Quality = JpegQuality };
await image.SaveAsync(output, encoder, ct);
}
member.PhotoPath = $"{AvatarsFolder}/{fileName}";
await _db.SaveChangesAsync(ct);
return FamilyMemberDto.From(member);
}
}
}

View file

@ -0,0 +1,78 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Options;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Features.Files;
/// <summary>
/// Slice: open the bytes of a <see cref="Domain.Files.FileAsset"/> for streaming back
/// to an authorized caller. Enforces per-family scoping: the asset must belong to the
/// caller's family (404 otherwise — indistinguishable from "does not exist").
/// </summary>
public static class DownloadFile
{
/// <summary>Resolved payload delivered to the endpoint to stream back.</summary>
/// <param name="Stream">Open readable stream; disposed by the endpoint after sending.</param>
/// <param name="ContentType">MIME type to set on the response.</param>
/// <param name="SizeBytes">Size of the stream in bytes.</param>
public sealed record FilePayload(Stream Stream, string ContentType, long SizeBytes);
/// <summary>Query carrying the target id.</summary>
public sealed record Query(Guid FileId) : IRequest<Result<FilePayload>>;
/// <summary>Handler — looks up the asset and opens a read stream on disk.</summary>
public sealed class Handler : IRequestHandler<Query, Result<FilePayload>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
private readonly FilesOptions _options;
/// <summary>Primary constructor.</summary>
public Handler(
ApplicationDbContext db,
ICurrentUserContext userContext,
IOptions<FilesOptions> options)
{
_db = db;
_userContext = userContext;
_options = options.Value;
}
/// <inheritdoc />
public async Task<Result<FilePayload>> HandleAsync(Query request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var asset = await _db.FileAssets
.AsNoTracking()
.FirstOrDefaultAsync(
a => a.Id == request.FileId && a.FamilyId == current.Family.Id,
cancellationToken);
if (asset is null)
{
return ApplicationError.NotFound("file.not_found", $"File {request.FileId} not found.");
}
var absolutePath = Path.Combine(_options.StorageRoot, asset.RelativePath);
if (!File.Exists(absolutePath))
{
return ApplicationError.NotFound(
"file.bytes_missing",
$"File {request.FileId} metadata exists but bytes are missing on disk.");
}
var stream = File.OpenRead(absolutePath);
return new FilePayload(stream, asset.ContentType, asset.SizeBytes);
}
}
}

View file

@ -0,0 +1,31 @@
using FamilyNido.Domain.Files;
namespace FamilyNido.Api.Features.Files;
/// <summary>Read-model projection of a <see cref="FileAsset"/> returned by the API.</summary>
/// <param name="Id">Stable asset identifier.</param>
/// <param name="ContentType">MIME type of the stored bytes.</param>
/// <param name="SizeBytes">Size of the stored bytes.</param>
/// <param name="Width">Image width in pixels, if known.</param>
/// <param name="Height">Image height in pixels, if known.</param>
/// <param name="OwnerMemberId">Member who uploaded the file.</param>
/// <param name="Url">Relative URL the Angular client uses to load the bytes.</param>
public sealed record FileAssetDto(
Guid Id,
string ContentType,
long SizeBytes,
int? Width,
int? Height,
Guid OwnerMemberId,
string Url)
{
/// <summary>Project a domain entity to the DTO shape used by the API.</summary>
public static FileAssetDto From(FileAsset a) => new(
Id: a.Id,
ContentType: a.ContentType,
SizeBytes: a.SizeBytes,
Width: a.Width,
Height: a.Height,
OwnerMemberId: a.OwnerMemberId,
Url: $"/api/files/{a.Id}");
}

View file

@ -0,0 +1,74 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Http;
using FamilyNido.Api.Shared.Mediator;
namespace FamilyNido.Api.Features.Files;
/// <summary>REST endpoints for the shared file-asset module.</summary>
public static class FileEndpoints
{
/// <summary>Registers <c>/api/files</c> endpoints on the given route group.</summary>
public static IEndpointRouteBuilder MapFileEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/files")
.WithTags("Files")
.RequireAuthorization(Policies.AuthenticatedUser);
// multipart/form-data: single "file" part + optional "module" (default "wall").
group.MapPost("/", UploadAsync).DisableAntiforgery();
group.MapGet("/{id:guid}", DownloadAsync);
return app;
}
private static async Task<IResult> UploadAsync(
HttpRequest request,
IMediator mediator,
CancellationToken ct)
{
if (!request.HasFormContentType)
{
return Results.BadRequest(new { code = "file.missing_multipart", message = "Upload must be multipart/form-data." });
}
var form = await request.ReadFormAsync(ct);
var file = form.Files.GetFile("file");
if (file is null || file.Length == 0)
{
return Results.BadRequest(new { code = "file.missing", message = "No file provided." });
}
var module = form["module"].ToString();
if (string.IsNullOrWhiteSpace(module))
{
module = "wall";
}
await using var stream = file.OpenReadStream();
var command = new UploadFile.Command(stream, file.ContentType, file.Length, module);
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess
? Results.Created(result.Value.Url, result.Value)
: result.Error.ToHttpResult();
}
private static async Task<IResult> DownloadAsync(
Guid id,
IMediator mediator,
CancellationToken ct)
{
var result = await mediator.SendAsync(new DownloadFile.Query(id), ct);
if (!result.IsSuccess)
{
return result.Error.ToHttpResult();
}
var payload = result.Value;
// Results.Stream disposes the stream after writing; caches for 1 day —
// fine because the URL carries the id and file content is immutable once written.
return Results.Stream(
payload.Stream,
contentType: payload.ContentType,
enableRangeProcessing: true);
}
}

View file

@ -0,0 +1,115 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Options;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Files;
using FamilyNido.Persistence;
using Microsoft.Extensions.Options;
namespace FamilyNido.Api.Features.Files;
/// <summary>
/// Slice: persist an uploaded image to the configured storage root + create a
/// <see cref="FileAsset"/> metadata row. Files are scoped per family (used later
/// to authorize downloads) and placed under a module-specific subfolder
/// (<c>wall/yyyy/MM/&lt;uuid&gt;.ext</c>). First consumer is the wall module; the
/// same endpoint will back recipes and health attachments later on.
/// </summary>
public static class UploadFile
{
/// <summary>Command carrying the raw stream + metadata.</summary>
/// <param name="Stream">Open readable stream positioned at the start of the file.</param>
/// <param name="ContentType">MIME type as declared by the client.</param>
/// <param name="SizeBytes">Size of the stream in bytes.</param>
/// <param name="Module">Logical folder under the storage root (e.g. <c>wall</c>).</param>
public sealed record Command(
Stream Stream,
string ContentType,
long SizeBytes,
string Module) : IRequest<Result<FileAssetDto>>;
/// <summary>Handler — writes to disk then persists the asset row.</summary>
public sealed class Handler : IRequestHandler<Command, Result<FileAssetDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
private readonly FilesOptions _options;
private readonly TimeProvider _clock;
/// <summary>Primary constructor.</summary>
public Handler(
ApplicationDbContext db,
ICurrentUserContext userContext,
IOptions<FilesOptions> options,
TimeProvider clock)
{
_db = db;
_userContext = userContext;
_options = options.Value;
_clock = clock;
}
/// <inheritdoc />
public async Task<Result<FileAssetDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
if (!_options.AllowedImageTypes.Contains(request.ContentType, StringComparer.OrdinalIgnoreCase))
{
return ApplicationError.Validation(
"file.unsupported_content_type",
$"Content type '{request.ContentType}' is not accepted.");
}
if (request.SizeBytes <= 0 || request.SizeBytes > _options.MaxImageBytes)
{
return ApplicationError.Validation(
"file.size_out_of_range",
$"File size must be between 1 byte and {_options.MaxImageBytes} bytes.");
}
var assetId = Guid.CreateVersion7();
var now = _clock.GetUtcNow();
var extension = ExtensionFor(request.ContentType);
var relativePath = $"{request.Module}/{now.Year:D4}/{now.Month:D2}/{assetId}{extension}";
var absolutePath = Path.Combine(_options.StorageRoot, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)!);
await using (var fs = File.Create(absolutePath))
{
await request.Stream.CopyToAsync(fs, cancellationToken);
}
var asset = new FileAsset
{
Id = assetId,
FamilyId = current.Family.Id,
OwnerMemberId = current.Member.Id,
RelativePath = relativePath,
ContentType = request.ContentType,
SizeBytes = request.SizeBytes,
};
_db.FileAssets.Add(asset);
await _db.SaveChangesAsync(cancellationToken);
return FileAssetDto.From(asset);
}
private static string ExtensionFor(string contentType) => contentType.ToLowerInvariant() switch
{
"image/jpeg" => ".jpg",
"image/png" => ".png",
"image/webp" => ".webp",
"image/heic" => ".heic",
"image/heif" => ".heif",
_ => string.Empty,
};
}
}

View file

@ -0,0 +1,93 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Health;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Health;
/// <summary>Slice for <c>POST /api/health/members/{memberId}/medications</c>.</summary>
public static class AddMedication
{
/// <summary>Command for a new medication row.</summary>
public sealed record Command(
Guid MemberId,
string Name,
string? Dose,
string? Frequency,
DateOnly StartDate,
DateOnly? EndDate,
string? Instructions) : IRequest<Result<MedicationDto>>;
/// <summary>Input validation.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator with all rules registered.</summary>
public Validator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(120);
RuleFor(x => x.Dose).MaximumLength(80);
RuleFor(x => x.Frequency).MaximumLength(120);
RuleFor(x => x.Instructions).MaximumLength(2000);
RuleFor(x => x.EndDate)
.GreaterThanOrEqualTo(x => x.StartDate)
.When(x => x.EndDate is not null)
.WithMessage("La fecha de fin debe ser posterior o igual a la de inicio.");
}
}
/// <summary>Inserts after verifying family scope.</summary>
public sealed class Handler : IRequestHandler<Command, Result<MedicationDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<MedicationDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var memberOk = await _db.FamilyMembers
.AnyAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, cancellationToken);
if (!memberOk)
{
return ApplicationError.NotFound("family_member.not_found", $"Member {request.MemberId} not found.");
}
var entry = new Medication
{
FamilyMemberId = request.MemberId,
Name = request.Name.Trim(),
Dose = Trim(request.Dose),
Frequency = Trim(request.Frequency),
StartDate = request.StartDate,
EndDate = request.EndDate,
Instructions = Trim(request.Instructions),
};
_db.Medications.Add(entry);
await _db.SaveChangesAsync(cancellationToken);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var isActive = entry.EndDate is null || entry.EndDate >= today;
return new MedicationDto(entry.Id, entry.Name, entry.Dose, entry.Frequency, entry.StartDate, entry.EndDate, entry.Instructions, isActive);
}
private static string? Trim(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View file

@ -0,0 +1,82 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Domain.Health;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Health;
/// <summary>Slice for <c>POST /api/health/members/{memberId}/vaccinations</c>.</summary>
public static class AddVaccination
{
/// <summary>Command for a new vaccination row.</summary>
public sealed record Command(
Guid MemberId,
string Name,
DateOnly Date,
DateOnly? NextDueDate,
string? Notes) : IRequest<Result<VaccinationDto>>;
/// <summary>Input validation.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator with all rules registered.</summary>
public Validator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(120);
RuleFor(x => x.Notes).MaximumLength(2000);
RuleFor(x => x.NextDueDate)
.GreaterThanOrEqualTo(x => x.Date)
.When(x => x.NextDueDate is not null)
.WithMessage("La próxima dosis debe ser posterior o igual a la fecha actual.");
}
}
/// <summary>Inserts after verifying family scope.</summary>
public sealed class Handler : IRequestHandler<Command, Result<VaccinationDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<VaccinationDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var memberOk = await _db.FamilyMembers
.AnyAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, cancellationToken);
if (!memberOk)
{
return ApplicationError.NotFound("family_member.not_found", $"Member {request.MemberId} not found.");
}
var entry = new Vaccination
{
FamilyMemberId = request.MemberId,
Name = request.Name.Trim(),
Date = request.Date,
NextDueDate = request.NextDueDate,
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
};
_db.Vaccinations.Add(entry);
await _db.SaveChangesAsync(cancellationToken);
return new VaccinationDto(entry.Id, entry.Name, entry.Date, entry.NextDueDate, entry.Notes);
}
}
}

View file

@ -0,0 +1,54 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Health;
/// <summary>Slice for <c>DELETE /api/health/medications/{id}</c>.</summary>
public static class DeleteMedication
{
/// <summary>Command carrying the row to delete.</summary>
public sealed record Command(Guid Id) : IRequest<Result<Unit>>;
/// <summary>Empty success payload.</summary>
public sealed record Unit;
/// <summary>Removes the row after verifying family scope.</summary>
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var entry = await _db.Medications
.Include(m => m.FamilyMember)
.FirstOrDefaultAsync(m => m.Id == request.Id, cancellationToken);
if (entry is null || entry.FamilyMember!.FamilyId != current.Family.Id)
{
return ApplicationError.NotFound("medication.not_found", $"Medication {request.Id} not found.");
}
_db.Medications.Remove(entry);
await _db.SaveChangesAsync(cancellationToken);
return new Unit();
}
}
}

View file

@ -0,0 +1,54 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Health;
/// <summary>Slice for <c>DELETE /api/health/vaccinations/{id}</c>.</summary>
public static class DeleteVaccination
{
/// <summary>Command carrying the row to delete.</summary>
public sealed record Command(Guid Id) : IRequest<Result<Unit>>;
/// <summary>Empty success payload.</summary>
public sealed record Unit;
/// <summary>Removes the row after verifying family scope.</summary>
public sealed class Handler : IRequestHandler<Command, Result<Unit>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<Unit>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var entry = await _db.Vaccinations
.Include(v => v.FamilyMember)
.FirstOrDefaultAsync(v => v.Id == request.Id, cancellationToken);
if (entry is null || entry.FamilyMember!.FamilyId != current.Family.Id)
{
return ApplicationError.NotFound("vaccination.not_found", $"Vaccination {request.Id} not found.");
}
_db.Vaccinations.Remove(entry);
await _db.SaveChangesAsync(cancellationToken);
return new Unit();
}
}
}

View file

@ -0,0 +1,82 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Health;
/// <summary>
/// Slice for <c>GET /api/health/members/{memberId}</c>. Returns the member's
/// health card with profile, vaccinations and medications in a single
/// request — the screen needs all three at once so paging would buy nothing.
/// </summary>
public static class GetMemberHealth
{
/// <summary>Query carrying the target member id.</summary>
public sealed record Query(Guid MemberId) : IRequest<Result<MemberHealthDto>>;
/// <summary>Resolves the member, validates family scope, hydrates the DTO.</summary>
public sealed class Handler : IRequestHandler<Query, Result<MemberHealthDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<MemberHealthDto>> HandleAsync(Query request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var member = await _db.FamilyMembers
.AsNoTracking()
.FirstOrDefaultAsync(m => m.Id == request.MemberId && m.FamilyId == current.Family.Id, cancellationToken);
if (member is null)
{
return ApplicationError.NotFound("family_member.not_found", $"Member {request.MemberId} not found.");
}
var profile = await _db.HealthProfiles
.AsNoTracking()
.Where(p => p.FamilyMemberId == request.MemberId)
.Select(p => new HealthProfileDto(p.BloodType, p.Allergies, p.ChronicConditions, p.Notes))
.FirstOrDefaultAsync(cancellationToken);
var vaccinations = await _db.Vaccinations
.AsNoTracking()
.Where(v => v.FamilyMemberId == request.MemberId)
.OrderByDescending(v => v.Date)
.Select(v => new VaccinationDto(v.Id, v.Name, v.Date, v.NextDueDate, v.Notes))
.ToListAsync(cancellationToken);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var medications = await _db.Medications
.AsNoTracking()
.Where(m => m.FamilyMemberId == request.MemberId)
.OrderByDescending(m => m.StartDate)
.Select(m => new MedicationDto(
m.Id,
m.Name,
m.Dose,
m.Frequency,
m.StartDate,
m.EndDate,
m.Instructions,
m.EndDate == null || m.EndDate >= today))
.ToListAsync(cancellationToken);
return new MemberHealthDto(member.Id, member.DisplayName, profile, vaccinations, medications);
}
}
}

View file

@ -0,0 +1,57 @@
namespace FamilyNido.Api.Features.Health;
/// <summary>Aggregate "health card" for a single member returned by GET /api/health/members/{id}.</summary>
/// <param name="MemberId">Family member id.</param>
/// <param name="MemberDisplayName">Convenience copy of the member's name for headers.</param>
/// <param name="Profile">Static profile (blood, allergies, conditions, notes). Null when never saved.</param>
/// <param name="Vaccinations">Vaccinations sorted by date descending.</param>
/// <param name="Medications">Medications sorted by start date descending.</param>
public sealed record MemberHealthDto(
Guid MemberId,
string MemberDisplayName,
HealthProfileDto? Profile,
IReadOnlyList<VaccinationDto> Vaccinations,
IReadOnlyList<MedicationDto> Medications);
/// <summary>Body of the static health profile.</summary>
/// <param name="BloodType">"A+", "O-", "AB+"… or null when unknown.</param>
/// <param name="Allergies">Free-text list of allergies (multi-line allowed).</param>
/// <param name="ChronicConditions">Free-text list of chronic conditions.</param>
/// <param name="Notes">Free-text general notes.</param>
public sealed record HealthProfileDto(
string? BloodType,
string? Allergies,
string? ChronicConditions,
string? Notes);
/// <summary>Single vaccination row.</summary>
/// <param name="Id">Stable id.</param>
/// <param name="Name">Vaccine name.</param>
/// <param name="Date">Date administered.</param>
/// <param name="NextDueDate">Next due date when known.</param>
/// <param name="Notes">Optional notes.</param>
public sealed record VaccinationDto(
Guid Id,
string Name,
DateOnly Date,
DateOnly? NextDueDate,
string? Notes);
/// <summary>Single medication row.</summary>
/// <param name="Id">Stable id.</param>
/// <param name="Name">Medication name.</param>
/// <param name="Dose">Dose string (free text).</param>
/// <param name="Frequency">Frequency string (free text).</param>
/// <param name="StartDate">Start date.</param>
/// <param name="EndDate">End date when finite.</param>
/// <param name="Instructions">Optional instructions.</param>
/// <param name="IsActive">Computed: true when EndDate is null or >= today (in UTC).</param>
public sealed record MedicationDto(
Guid Id,
string Name,
string? Dose,
string? Frequency,
DateOnly StartDate,
DateOnly? EndDate,
string? Instructions,
bool IsActive);

View file

@ -0,0 +1,159 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Http;
using FamilyNido.Api.Shared.Mediator;
using FluentValidation;
namespace FamilyNido.Api.Features.Health;
/// <summary>
/// Endpoints for the health module. Gated to Admin/Adult roles — Guests
/// (children with their own accounts in the future) never see this surface.
/// </summary>
public static class HealthEndpoints
{
/// <summary>Registers <c>/api/health/*</c> routes on the given builder.</summary>
public static IEndpointRouteBuilder MapHealthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/health").WithTags("Health");
group.MapGet("/members/{memberId:guid}", GetMemberAsync)
.RequireAuthorization(Policies.Adult);
group.MapPut("/members/{memberId:guid}/profile", UpsertProfileAsync)
.RequireAuthorization(Policies.Adult);
group.MapPost("/members/{memberId:guid}/vaccinations", AddVaccinationAsync)
.RequireAuthorization(Policies.Adult);
group.MapPut("/vaccinations/{id:guid}", UpdateVaccinationAsync)
.RequireAuthorization(Policies.Adult);
group.MapDelete("/vaccinations/{id:guid}", DeleteVaccinationAsync)
.RequireAuthorization(Policies.Adult);
group.MapPost("/members/{memberId:guid}/medications", AddMedicationAsync)
.RequireAuthorization(Policies.Adult);
group.MapPut("/medications/{id:guid}", UpdateMedicationAsync)
.RequireAuthorization(Policies.Adult);
group.MapDelete("/medications/{id:guid}", DeleteMedicationAsync)
.RequireAuthorization(Policies.Adult);
return app;
}
private static async Task<IResult> GetMemberAsync(Guid memberId, IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new GetMemberHealth.Query(memberId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> UpsertProfileAsync(
Guid memberId,
UpsertHealthProfileBody body,
IValidator<UpsertHealthProfile.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var command = new UpsertHealthProfile.Command(
memberId, body.BloodType, body.Allergies, body.ChronicConditions, body.Notes);
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null) return validation;
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> AddVaccinationAsync(
Guid memberId,
VaccinationBody body,
IValidator<AddVaccination.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var command = new AddVaccination.Command(memberId, body.Name, body.Date, body.NextDueDate, body.Notes);
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null) return validation;
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> UpdateVaccinationAsync(
Guid id,
VaccinationBody body,
IValidator<UpdateVaccination.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var command = new UpdateVaccination.Command(id, body.Name, body.Date, body.NextDueDate, body.Notes);
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null) return validation;
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> DeleteVaccinationAsync(Guid id, IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new DeleteVaccination.Command(id), ct);
return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult();
}
private static async Task<IResult> AddMedicationAsync(
Guid memberId,
MedicationBody body,
IValidator<AddMedication.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var command = new AddMedication.Command(
memberId, body.Name, body.Dose, body.Frequency, body.StartDate, body.EndDate, body.Instructions);
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null) return validation;
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> UpdateMedicationAsync(
Guid id,
MedicationBody body,
IValidator<UpdateMedication.Command> validator,
IMediator mediator,
CancellationToken ct)
{
var command = new UpdateMedication.Command(
id, body.Name, body.Dose, body.Frequency, body.StartDate, body.EndDate, body.Instructions);
var validation = await validator.ValidateOrProblemAsync(command, ct);
if (validation is not null) return validation;
var result = await mediator.SendAsync(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : result.Error.ToHttpResult();
}
private static async Task<IResult> DeleteMedicationAsync(Guid id, IMediator mediator, CancellationToken ct)
{
var result = await mediator.SendAsync(new DeleteMedication.Command(id), ct);
return result.IsSuccess ? Results.NoContent() : result.Error.ToHttpResult();
}
/// <summary>Body for the health-profile upsert (memberId comes from the route).</summary>
public sealed record UpsertHealthProfileBody(
string? BloodType,
string? Allergies,
string? ChronicConditions,
string? Notes);
/// <summary>Body shared by add/update vaccination (memberId/id come from the route).</summary>
public sealed record VaccinationBody(
string Name,
DateOnly Date,
DateOnly? NextDueDate,
string? Notes);
/// <summary>Body shared by add/update medication.</summary>
public sealed record MedicationBody(
string Name,
string? Dose,
string? Frequency,
DateOnly StartDate,
DateOnly? EndDate,
string? Instructions);
}

View file

@ -0,0 +1,88 @@
using FamilyNido.Api.Features.Auth;
using FamilyNido.Api.Shared.Errors;
using FamilyNido.Api.Shared.Mediator;
using FamilyNido.Api.Shared.Outcomes;
using FamilyNido.Persistence;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Api.Features.Health;
/// <summary>Slice for <c>PUT /api/health/medications/{id}</c>.</summary>
public static class UpdateMedication
{
/// <summary>Command replacing the editable fields of a medication row.</summary>
public sealed record Command(
Guid Id,
string Name,
string? Dose,
string? Frequency,
DateOnly StartDate,
DateOnly? EndDate,
string? Instructions) : IRequest<Result<MedicationDto>>;
/// <summary>Input validation.</summary>
public sealed class Validator : AbstractValidator<Command>
{
/// <summary>Creates the validator with all rules registered.</summary>
public Validator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(120);
RuleFor(x => x.Dose).MaximumLength(80);
RuleFor(x => x.Frequency).MaximumLength(120);
RuleFor(x => x.Instructions).MaximumLength(2000);
RuleFor(x => x.EndDate)
.GreaterThanOrEqualTo(x => x.StartDate)
.When(x => x.EndDate is not null);
}
}
/// <summary>Updates after verifying family scope.</summary>
public sealed class Handler : IRequestHandler<Command, Result<MedicationDto>>
{
private readonly ApplicationDbContext _db;
private readonly ICurrentUserContext _userContext;
/// <summary>Primary constructor.</summary>
public Handler(ApplicationDbContext db, ICurrentUserContext userContext)
{
_db = db;
_userContext = userContext;
}
/// <inheritdoc />
public async Task<Result<MedicationDto>> HandleAsync(Command request, CancellationToken cancellationToken)
{
var current = await _userContext.GetAsync(cancellationToken);
if (current is null)
{
return ApplicationError.Forbidden("auth.not_linked", "Caller is not linked to a family member.");
}
var entry = await _db.Medications
.Include(m => m.FamilyMember)
.FirstOrDefaultAsync(m => m.Id == request.Id, cancellationToken);
if (entry is null || entry.FamilyMember!.FamilyId != current.Family.Id)
{
return ApplicationError.NotFound("medication.not_found", $"Medication {request.Id} not found.");
}
entry.Name = request.Name.Trim();
entry.Dose = Trim(request.Dose);
entry.Frequency = Trim(request.Frequency);
entry.StartDate = request.StartDate;
entry.EndDate = request.EndDate;
entry.Instructions = Trim(request.Instructions);
await _db.SaveChangesAsync(cancellationToken);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var isActive = entry.EndDate is null || entry.EndDate >= today;
return new MedicationDto(entry.Id, entry.Name, entry.Dose, entry.Frequency, entry.StartDate, entry.EndDate, entry.Instructions, isActive);
}
private static string? Trim(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

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