Angel Campa
Colour theme

WorkThe .NET year

GatherGrove

Ran membership, events, payments, and email campaigns for community clubs, keeping every club's member data walled off from every other club's by design.

Ran on
Railway (API), Cloudflare Workers (web, via OpenNext)
Ran
May 2025 to July 2026
Domain
Community and membership management
Source
community-membership-saas-gathergrove-snapshot

Built with

  • .NET 9
  • EF Core 9
  • PostgreSQL
  • Redis
  • SignalR
  • Stripe Connect
  • Next.js 15
  • React 19
  • React Native / Expo
  • Cloudflare Workers

Hosted services

  • Neon (PostgreSQL)
  • Redis
  • Stripe Connect
  • Resend
  • Sentry
  • PostHog
GatherGrove admin dashboard: 249 total and active members, 32 upcoming events, dues collected year to date, and a Current Plan card showing 249 of 2,000 members used on the Expand tier
The admin dashboard, captured against a seeded database of 3 clubs across all pricing tiers.

The decision

Tenant isolation between clubs was enforced at the edge instead of inside EF Core. The usual answer for a multi-tenant app is a global query filter, HasQueryFilter, that scopes every query to the caller’s tenant automatically. A filter that silently fails to apply, because a query used raw SQL or bypassed the entity, leaks data with no signal to catch it.

Instead, a single globally registered MVC action filter, ClubIdValidationFilter, ran ahead of every one of the 65 controllers’ handlers and logged a cross-tenant attempt as a security event rather than letting it fall through to an empty result set. It compared the route’s {clubId} against the caller’s JWT claim, and authorization policies plus composite (ClubId, …) unique constraints backed it up as defense in depth.

The tradeoff: the filter’s protection stopped at the route, so a query that filtered incorrectly inside its own handler, the one case HasQueryFilter would have caught automatically, could still leak. What it bought instead was a check that ran before any of the 65 handlers executed and turned a cross-tenant attempt into a logged security event rather than a silent empty response.

Architecture

A .NET 9 API in four backend projects with inward-pointing dependencies served three clients: a Next.js 15 web app on Cloudflare Workers, a React Native mobile app, and background jobs. The API talked to PostgreSQL, Redis, Stripe Connect, and Resend, and streamed live updates over three SignalR hubs.

The database itself changed mid-project: a live cutover from SQL Server, which GatherGrove had run on for its first nine months, to PostgreSQL on 2026-02-17. The type mismatches surfaced in a day. Threading DateTimeKind.Utc through six services, so that PostgreSQL’s timestamptz (which rejects a DateTime with an Unspecified Kind that SQL Server had accepted silently) agreed with the code that talked to it, took five weeks.

By the numbers

Each figure carries where it came from and when it was measured. Open one to see the command behind it, where the repository recorded a command.

Production code (backend, web, mobile, shared tokens)
331,181 lines
Provenance

Source portfolio/METRICS.md § Code volume: verified

cloc backend/src client/src mobile/src shared/design-tokens --exclude-dir=node_modules,.next,bin,obj

Measured

Test code
463,430 lines
Provenance

Source portfolio/METRICS.md § Code volume: verified

Measured

Backend tests and coverage (NUnit)
6,193 tests passing
Provenance

Source README.md § test table

Measured

Web client tests and coverage (Jest)
10,932 tests passing
Provenance

Source README.md § test table

Measured

Mobile tests and coverage (Jest / RNTL)
5,605 tests passing
Provenance

Source README.md § test table

Measured

API controllers
65
Provenance

Source portfolio/METRICS.md § Surface area: verified by direct count

find backend/src/GatherGrove.API -iname "*Controller.cs" | wc -l

Measured

HTTP endpoints and database tables
384 endpoints, 84 tables
Provenance

Source README.md § By the numbers: Surface area

Measured

Commits
2,130 across 150 active days
Provenance

Source portfolio/METRICS.md § Commit history

Measured

Screens

Events list showing recurring classes with dates, per-location scheduling, and member vs. non-member pricing
Events: recurring series, member vs. non-member pricing, per-location scheduling
Communications screen for composing and scheduling email and push updates
Communications: campaigns, scheduling, delivery
Member engagement dashboard with per-member engagement scores and at-risk flags
Engagement tracking: scoring with at-risk detection
White-label branding settings: primary and secondary color pickers with hex values, a device-switchable live preview of the member-facing site, and inline warnings for color contrast and brand consistency
White-label branding: per-club colors, with a live preview that flags contrast and brand-consistency problems as you edit
Read the repositorycommunity-membership-saas-gathergrove-snapshot

Its portfolio/ directory holds the architecture, metrics and testing documents these figures were taken from.