AgendaMe is a scheduling and appointment management platform for independent service providers — fitness coaches, tutors, therapists, software instructors, and anyone who offers personalized one-to-one sessions. It replaces the juggle of calendar apps, contact spreadsheets, and direct messaging with one place to manage a service catalog, client roster, bookings, and communications through a native mobile app.
The repository and .NET projects retain the AgendaBuddy name; AgendaMe is the user-facing product name.
| Feature | Description |
|---|---|
| Identity & Auth | JWT RS256 authentication, per-IP rate limiting and self-clearing lockout on login/register, single-use refresh-token rotation |
| Provider onboarding | Sign up, connect a Stripe payout account, define a profession, add services, and accept bookings |
| Customer onboarding | Sign up, save a Stripe payment method, discover providers, and subscribe to one |
| Appointment lifecycle | Request, book, reschedule, complete, and soft-cancel appointments; status transitions are server-owned, never client-asserted |
| Calendar & availability | Providers configure per-weekday hours and time off; authenticated customers can see free slots while appointment details remain owner-only |
| Session notes | Provider attaches private notes to each appointment — visible only to the provider |
| Provider–customer messaging | Subscription-gated, MongoDB-backed threaded messaging with per-message and per-thread mark-read flows |
| Notifications | Best-effort in-app, email, and push delivery; unread badge/filtering plus per-item and bulk mark-read flows |
| Reporting dashboard | Localized booking volume, completion, cancellation, customer and revenue-availability metrics |
| Payments | Stripe Connect destination charges: authorization on provider confirmation, 90% provider allocation, 10% AgendaMe fee, capture on completion, and full release/refund on provider cancellation. Local development can record synthetic operations; non-local missing configuration fails closed |
| Mobile client (iOS + Android) | .NET MAUI app that reaches every capability above through a single Gateway address, with no fabricated fallback data |
Every read route that returns personal data requires authentication and enforces ownership; list endpoints are paginated and non-owners get a projected (not full) record.
Eight independent processes, orchestrated locally by .NET Aspire:
Identity — JWT issuance and credential management
Booking — appointment lifecycle, session notes, payments
Calendar — availability schedule and slot queries
Customer — customer profiles, messaging, notifications
Provider — provider profile, service catalog, reporting
Services — service definitions and fee management
Profession — profession/category seed data (anonymous reference data)
Gateway — YARP reverse proxy; the mobile client's only address
Shared entities, repositories, and domain services live in AgendaBuddy.Library; AgendaBuddy.Library.ServerAuth holds JWT validation and ownership guards. Six domain services use a four-project Clean Architecture split: thin *.Api endpoint modules dispatch MediatR commands and queries to *.Core, contracts live in *.Domain, and *.Infrastructure is intentionally empty until needed. Identity deliberately remains a direct-service exception. AgendaBuddy.EventAndCommands now contains only the shared audit EventStore kernel, not domain handlers. Messaging and notification fan-out are synchronous and in-process; there is no message broker.
The Gateway is the one thing that changed the shape of this diagram: MobileApp does not call the seven domain services directly. It calls the Gateway, which forwards api/v1/{service}/** to the matching destination via an explicit route allowlist — resolved live from Aspire service discovery, so a backend's dynamic port reassignment never needs the Gateway to restart. The Gateway has no business logic and does not validate the caller's JWT — it forwards it byte-for-byte, so the destination authenticates and authorizes exactly as it would a direct call.
┌─────────────────────────────────────────────────────┐
│ MobileApp (iOS / Android) — the only client │
└────────────────┬────────────────────────────────────┘
│ HTTP, api/v1/{service}/** only
┌───────────▼───────────┐
│ Gateway │ ← YARP, explicit allowlist, JWT passthrough
└───┬───┬───┬───┬───┬───┬┘
│ │ │ │ │ │
┌───▼┐┌▼───┐┌▼──┐┌▼──┐┌▼──┐┌▼────┐┌▼────────┐
│Book││Cal ││Cust││Prov││Svc││Prof ││Identity │ ← Minimal APIs
└──┬─┘└──┬─┘└──┬─┘└──┬─┘└─┬─┘└─┬───┘└────┬────┘
│ │ │ │ │ │ │
┌──▼─────▼─────▼─────▼────▼────▼─────────▼───┐
│ Library (shared) │
│ Entities · Services · Repository · Auth │
└──────────────────┬────────────────────────────┘
│
┌─────────────▼──────────────┐
│ Service *.Core handlers │
│ MediatR · audit writes │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
│ MongoDB │
└────────────────────────────┘
Every service (and the Gateway) calls AddServiceDefaults() exactly once, which supplies OpenTelemetry, /health//alive, service discovery, HTTP resilience, and PII-redacted telemetry.
| Layer | Technology |
|---|---|
| Language | C# / .NET 10 |
| Framework | ASP.NET Core 10 Minimal APIs |
| Orchestration | .NET Aspire 13 — hosting-only (AgendaBuddy.AppHost + AgendaBuddy.ServiceDefaults) |
| Gateway | YARP reverse proxy |
| Database | MongoDB (MongoDB.Driver pinned at 2.25.0 — do not add Aspire.MongoDB.Driver, see CLAUDE.md) |
| Messaging | MongoDB-backed messaging + synchronous in-process notification dispatch |
| Caching | IDistributedCache — cache-aside pattern, 5-min TTL |
| Auth | JWT RS256 — AddAgendaBuddyAuthentication(), keys via Aspire secret parameters |
| Payments | Stripe.net — non-charging recording gateway by default |
| Mobile | .NET MAUI (iOS + Android), routed through the Gateway |
| Testing | xUnit across three separate suites; no single command runs all of them (see Build & test) |
| Infrastructure | Aspire AppHost (primary) · Docker Compose (legacy fallback) · GitHub Actions CI |
| Observability | OpenTelemetry → Aspire dashboard, with a PII-redacting span processor |
agenda-buddy/
├── AgendaBuddy.AppHost/ # Aspire composition root — declares every resource, local vs. cloud shape
├── AgendaBuddy.ServiceDefaults/# OpenTelemetry, health/liveness, service discovery, HTTP resilience
├── AgendaBuddy.Library/ # Shared entities, services, repositories, tools
├── AgendaBuddy.Library.ServerAuth/ # JWT validation and ownership guards
├── AgendaBuddy.EventAndCommands/ # EventStore and audit infrastructure
├── AgendaBuddy.{Domain}.Api/ # HTTP endpoint and DI wiring for six split services
├── AgendaBuddy.{Domain}.Core/ # MediatR handlers for those services
├── AgendaBuddy.{Domain}.Domain/# Per-service commands, queries, DTOs and envelopes
├── AgendaBuddy.{Domain}.Infrastructure/ # Intentionally empty until needed
├── AgendaBuddy.Identity/ # Direct IdentityService-based auth service
├── AgendaBuddy.Gateway/ # YARP reverse proxy; MobileApp's only backend address
├── AgendaBuddy.MobileApp/ # .NET MAUI client (iOS + Android)
├── *.Tests/ # xUnit test projects mirroring each service
├── AgendaBuddy.IntegrationTests/ # real services over HTTP against a MongoDB Testcontainer
├── AgendaBuddy.MobileApp.Tests/ # Mobile tests under a net10.0 fallback TFM
├── bruno/agenda-buddy/ # Bruno API collection (hits the 7 services directly, bypassing the Gateway)
├── docs/api/openapi/ # generated OpenAPI specs — regenerate with scripts/generate-openapi.sh
├── compose/ # Docker Compose data fixtures
├── docs/pdlc/ # PDLC memory: CONSTITUTION, OVERVIEW, ROADMAP, STATE, episodes
└── docker-compose.yml # incomplete legacy fallback; AppHost is authoritative
- .NET 10 SDK
- A running container runtime — Docker Desktop, Podman, or Rancher Desktop. The AppHost provisions MongoDB as a container. (No Aspire workload install is needed — Aspire ships as NuGet packages, so
dotnet restoreis the only other prerequisite. If you're on Rancher Desktop,dockerlives at~/.rd/bin, not onPATH—export PATH="$HOME/.rd/bin:$PATH"first.) - To run the mobile client: the MAUI workload (
dotnet workload install maui) and, for iOS, Xcode + a simulator.
One command starts MongoDB, all seven API services, and the Gateway:
dotnet run --project AgendaBuddy.AppHostThe Aspire dashboard opens with the resource graph, health, logs, traces, and metrics. Ctrl+C stops the AppHost (see the shutdown gotcha below).
To also launch the mobile app against this stack, in an iOS simulator, with the Gateway's address auto-discovered:
./scripts/run-ios.shLocal AppHost runs capture Identity confirmation/reset emails in memory instead of sending through
Resend. After registering, use the Gateway URL printed by run-ios.sh to inspect the latest message or
open its confirmation link in the booted simulator:
GATEWAY_URL=http://localhost:<gateway-port> ./scripts/local-email.sh user@local.test
GATEWAY_URL=http://localhost:<gateway-port> ./scripts/local-email.sh user@local.test --open-iosThe inbox route exists only when the local AppHost sets both Security:Local and
Email:LocalCaptureEnabled; cloud deployments continue to use Resend. Confirmation email uses the
installed-app agendame://email button and also includes a six-digit code for manual entry.
The AppHost needs three values, held in user secrets scoped to AgendaBuddy.AppHost:
| Parameter | What it is |
|---|---|
jwt-public-key |
RSA public key (PEM) every service uses to verify tokens |
jwt-private-key |
RSA private key (PEM) Identity uses to sign them |
mongodb-password |
Root password for the local MongoDB container |
User secrets are per machine and per user, so every new host — and every fresh OS account — starts with none of them. Until they are set, the dashboard shows those parameters as ValueMissing and every service sits in Waiting, with nothing logged (see troubleshooting below). Set all three in one go:
# Matched RSA pair — Identity signs with the private key, all seven verify with the public one
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/jwt.key
openssl rsa -in /tmp/jwt.key -pubout -out /tmp/jwt.pub
dotnet user-secrets set "Parameters:jwt-private-key" "$(cat /tmp/jwt.key)" --project AgendaBuddy.AppHost
dotnet user-secrets set "Parameters:jwt-public-key" "$(cat /tmp/jwt.pub)" --project AgendaBuddy.AppHost
dotnet user-secrets set "Parameters:mongodb-password" "$(openssl rand -hex 16)" --project AgendaBuddy.AppHost
rm -f /tmp/jwt.key /tmp/jwt.pub
dotnet user-secrets list --project AgendaBuddy.AppHost # expect the three Parameters:* keysThree things to get right:
- The JWT keys must be a matched pair. Generating them independently leaves every request returning
401with nothing else obviously wrong. The commands above derive the public key from the private one, so they always match. - Each host can have its own pair. Nothing shares tokens across machines, so there is no need to copy secrets between them. If you do want identical tokens on two machines, copy both key values verbatim.
mongodb-passwordonly takes effect on a first-ever run. MongoDB ignoresMONGO_INITDB_ROOT_PASSWORDwhen/data/dbis non-empty, so if that host already has anagendabuddy.apphost-*-mongodb-datavolume from an earlier attempt, remove it first — otherwise authentication fails permanently.
Nothing here is ever written to the repository; this replaces the undocumented gitignored .env file the services previously relied on. Because all three are declared as secret parameters, the dashboard masks them.
mongodb-password is declared explicitly rather than generated because MongoDB runs on a persistent data volume: a generated password changes on every run while the volume keeps the root user created by the first one, so nothing would ever authenticate again.
User secrets only load in the Development environment, which is why AgendaBuddy.AppHost/Properties/launchSettings.json sets DOTNET_ENVIRONMENT=Development. Never delete that file. Do not run the AppHost with --no-launch-profile unless you export DOTNET_ENVIRONMENT=Development yourself — either mistake reproduces ISSUE-001, where the whole graph hangs silently.
You do not need to set a MongoDB connection string: the AppHost injects it.
"Docker is not running" / the resources never leave Starting. Start Docker Desktop (or podman machine start / Rancher Desktop) and re-run. The AppHost cannot provision MongoDB without it.
Every service sits in Waiting forever, with nothing logged. A service is only scheduled once its parameters resolve and the resources it waits for are healthy — neither of which is reported as an error, which is what makes this silent. Two causes, both diagnosable from the dashboard's parameter resources:
- A parameter shows
ValueMissing. Its value isn't in user secrets, or the AppHost isn't running inDevelopmentand so never loaded them. CheckDOTNET_ENVIRONMENTanddotnet user-secrets list --project AgendaBuddy.AppHost. mongodbnever reachesHealthy. Usually the data volume was initialised with a different root password, so the health check can't authenticate.MONGO_INITDB_ROOT_PASSWORDis ignored on a non-empty/data/db, so changing the parameter is not enough — reset the volume:
docker volume ls | grep mongodb-data # find it: agendabuddy.apphost-<hash>-mongodb-data
docker volume rm <name> # destroys local dev data onlyA service fails immediately with No MongoDB connection string found. Set one of: …. You are running that service directly (dotnet run --project AgendaBuddy.Booking.Api) rather than through the AppHost. Either start the AppHost instead, or export the connection string yourself:
export ConnectionStrings__mongodb='mongodb://localhost:27017'The committed Atlas credential has been removed from every appsettings*.json (it is still recoverable from git history and still valid — rotation is a human-only action, see ISSUE-002), and the keys were intentionally left in place as empty slots. So a standalone or Compose run now fails fast with that message instead of silently connecting to a shared cluster.
Shutting down leaves orphaned processes. SIGTERM on the AppHost has repeatedly left every service process running after the AppHost itself exits (a known, recurring gotcha — not fixed, worked around). If dotnet run --project AgendaBuddy.Booking.Api fails with "address already in use" after a Ctrl-C, find and kill the orphans:
pkill -f "agenda-buddy/.*bin/Debug"MobileApp is the only client, and it reaches the backend through the Gateway, and only the Gateway. For curl or Bruno, prefix every route with api/v1/{service} and use http://localhost:6080 during an AppHost run. The bruno/agenda-buddy/ collection follows that path for application requests; only its health folder calls services directly because /health and /alive are deliberately outside the Gateway allowlist.
The AppHost assigns all seven service host ports dynamically. The Gateway is the deliberate exception: its local host port is pinned to 6080 so the mobile client and Bruno have one stable address. Read direct service URLs from the dashboard only when diagnosing a service or running the Bruno health requests. Two consequences:
- Two people (or two branches) can run the stack simultaneously without colliding.
scripts/seed/seed-mongo.shhardcodesmongo:27017and needs the assigned port to work. It also targetsProviderDbandCustomerDb, which no service reads. Neither is fixed here — treat that script as stale.
The Compose files are retained as an incomplete legacy fallback. Only Identity is active; several old service entries are commented out, the broker containers are dead configuration, and there is no Gateway. Use the AppHost for a working full stack.
docker compose -f docker-compose.yml -f docker-compose.override.yml up -d
docker compose downNote the Compose path now needs ConnectionStrings supplied, for the reason above.
Every service (and the Gateway) exposes two anonymous endpoints, for orchestrator probes:
| Path | Purpose | Healthy | Unhealthy |
|---|---|---|---|
/health |
Readiness — runs every check, including MongoDB connectivity | 200 Healthy |
503 Unhealthy |
/alive |
Liveness — only checks tagged live; does not touch MongoDB |
200 Healthy |
503 Unhealthy |
The split is deliberate: point your restart probe at /alive and your traffic probe at /health. When MongoDB is unreachable, /health goes 503 so the service stops receiving traffic while /alive stays 200, so nothing restarts a process that is running correctly and merely waiting on its database. Response bodies are a bare status word — no check names or exception detail. /health results are cached for 5 seconds, so probing it in a loop does not multiply database round-trips.
The Aspire dashboard exposes environment variables, configuration, logs, and traces for every resource. Secret parameters are masked, but treat the dashboard as privileged: do not expose its port beyond localhost, and do not screenshot it into a public issue.
The dev Azure environment is deployed through azure.yaml, .github/workflows/deploy.yml, and the
Terraform bootstrap in infra/terraform/. A successful main pipeline automatically redeploys relevant
backend changes; an hourly drift workflow compares the deployed SHA with main and repairs missed path-filter
deployments. Only the Gateway has public ingress. See docs/deployment.md and
DEPLOYMENTS.md for the current runbook and evidence.
Stripe marketplace-payment configuration has separate sandbox and production runbooks, with the integration contract summarized in docs/stripe/README.md.
The original Atlas credential committed in git history remains a separate human-owned rotation risk; see ISSUE-002.
→ docs/deployment.md for the full procedure, what is verified, and the list of gaps between this and a production posture.
dotnet restore
dotnet build --no-restoreThree separate test commands — no single command runs all suites:
# Backend tests across the backend solution filter
dotnet test agenda-buddy-backend.slnf --collect:"XPlat Code Coverage"
# Integration tests: real services over HTTP against a MongoDB Testcontainer
dotnet test AgendaBuddy.IntegrationTests/AgendaBuddy.IntegrationTests.csproj /p:MobileWorkloads=false
# Mobile tests; live-Identity acceptance tests skip when the service is unavailable
dotnet test AgendaBuddy.MobileApp.Tests/AgendaBuddy.MobileApp.Tests.csproj /p:MobileWorkloads=falseagenda-buddy-backend.slnf is the solution minus MobileApp and AgendaBuddy.IntegrationTests (the latter is excluded so the unit gate stays Docker-free) — CI runs it as the fast loop. The Integration project has a ProjectReference to MobileApp.csproj, so always pass /p:MobileWorkloads=false when restoring or building it directly, or it pulls in MAUI's android/ios TargetFrameworks and fails on a machine with no MAUI workloads installed.
Secrets are never stored in source. Under the AppHost you do not need to set the JWT keys or the connection string — Aspire prompts for the keys once and injects the connection string. The table below applies when running a service standalone or via Compose:
| Variable | Service | Description | Under the AppHost |
|---|---|---|---|
JWT_PRIVATE_KEY |
Identity | RSA private key (PEM) for JWT signing | supplied from the jwt-private-key secret parameter |
JWT_PUBLIC_KEY |
All services | RSA public key (PEM) for JWT verification | supplied from the jwt-public-key secret parameter |
Payments:Stripe:ApiKey |
Booking / Library | Selects the real Stripe gateway; unset by default, which selects a non-charging recording gateway instead | not set by the AppHost — configure explicitly if you want real Stripe |
ConnectionStrings__mongodb |
All services | MongoDB connection string | injected automatically |
The connection string is resolved in this order, first non-empty winning: ConnectionStrings:mongodb, MongoDbSettings:ConnectionString, MongoDB:ConnectionString, LibrarySettings:MongoDB:ConnectionString. If none resolves, the service fails at startup with a message naming all four.
Two security controls default OFF and are gated on configuration rather than environment name, because every service runs as Production under the local AppHost: Security:RateLimiting:Enabled and Security:Hsts:Enabled. The AppHost sets Security__Local=true locally (both stay off, no warning logged) and turns both on in the cloud graph. Each service warns loudly at startup, naming the key, if a control is off outside a local run.
- Repository pattern — all DB access via
IRepository<T>/MongoDbRepository<T>; no raw MongoDB queries outside the repository.FindOneAndUpdateAsyncis the only partial-update primitive and never upserts - Cache-aside —
CacheAsideextension onIDistributedCache(semaphore-guarded) used for all read-heavy queries.⚠️ No cache invalidation exists anywhere yet — a provider who finishes onboarding can be absent from discovery for up to 5 minutes - Ownership guard —
OwnershipGuard.AssertOwner(user, email)enforces that callers can only mutate their own resources; throwsForbiddenException(403) on violation, mapped centrally - CQRS — six domain services dispatch to MediatR handlers in their own
*.Coreprojects; command results are persisted to the shared EventStore audit trail - Server-owned state transitions — appointment status changes only through
AppointmentEntity.TransitionTo, applied via a dedicated route;PUTignores a client-asserted status - Explicit route allowlist, never a catch-all — the Gateway's
_routeSpecsis the single source of truth for what the mobile client can reach; a backend route invisible here is invisible to the app, with nothing failing loudly - Best-effort notification fan-out — producers call
INotificationDispatcher; inbox, email, and push failures are isolated, with no broker, outbox, or retry
The detailed feature history and current backlog live in ROADMAP.md; operational
state lives in STATE.md. Beads is the durable source for open work (bd ready,
bd list --status=open). Keeping the full feature table in one place avoids this README becoming a second,
stale roadmap.
Branch naming: feat/<F-NNN>-<kebab-case-name> for feature work.
Commit format: <type>(<scope>): <description> (types: feat fix chore docs test refactor perf ci)
All PRs target main and require CI to pass — build-and-test, Integration — real services + MongoDB, Mobile — Android Build, Mobile — iOS Build, and Mobile — Unit Tests.