Engineering hand-off · v0.1

Drupal 11 content model & API spec

What to build on the backend, the JSON:API surface it exposes, and how Next.js consumes it. Targets Drupal 11.x on Pantheon with a PostGIS sidecar for geo queries.

Drupal 11.x PHP 8.3 Pantheon · Performance Medium JSON:API + custom REST PostgreSQL 15 + PostGIS 3.4 Next.js 14 App Router

Overview & architecture

The Courts is a decoupled (headless) Drupal site. All business data — facilities, courts, bookings, reviews, memberships — lives as Drupal entities and is exposed primarily through core JSON:API. A small set of custom REST controllers handles geo queries (against a PostGIS sidecar), booking writes (which wrap a Stripe PaymentIntent in a transaction), and webhook receivers.

┌──────────────┐   ┌──────────────────┐   ┌────────────────┐
│ Next.js 14   │   │ Drupal 11        │   │ MariaDB        │
│ (Vercel)     │──▶│ JSON:API + REST  │──▶│ on Pantheon    │
│ App Router   │   │ on Pantheon Live │   │ (entities)     │
└──────────────┘   └────────┬─────────┘   └────────────────┘
                            │
                            ├──────────┬──────────────────┐
                            ▼          ▼                  ▼
                    ┌───────────────┐ ┌────────┐ ┌──────────────┐
                    │ Postgres +    │ │ Stripe │ │ S3 (media    │
                    │ PostGIS       │ │ Connect│ │ via s3fs)    │
                    │ (geo sidecar) │ │        │ │              │
                    └───────────────┘ └────────┘ └──────────────┘

Design rules

Module manifest

Core modules to enable

Standard install profile plus:

drush en -y \
  node taxonomy field path image media media_library file \
  views views_ui datetime datetime_range \
  jsonapi serialization basic_auth \
  workflows content_moderation \
  pathauto token \
  big_pipe dynamic_page_cache

Contrib modules

ModuleVersionPurpose
simple_oauth^6.0OAuth2 + JWT issuer for the mobile/web apps
social_auth_apple, social_auth_google^4.0Sign in with Apple / Google
jsonapi_extras^3.25Sparse-field defaults, virtual fields, override resource paths
jsonapi_resources^1.1Plugin base for custom JSON:API resources (geo bbox, availability)
geofield^1.xGeo field type + Geo PHP integration
address^2.0Proper postal address field with country logic
smart_date^4.0Better datetime widgets for availability rules
paragraphs^1.18Flexible nested content (hours, court groups)
s3fs^3.xS3-backed file system — required on Pantheon (read-only FS)
advancedqueue^1.xBackground jobs (push delivery, Stripe webhooks, payouts)
state_machine^1.xBooking state machine
pantheon_advanced_page_cache^2.xCache-tag-aware purging at the Pantheon edge
maillog (dev only)^1.xCapture outbound mail in Dev/Test

Custom modules to build

ModuleOwns
tc_courtFacility + Court node bundles, related field config, view modes
tc_bookingBooking content entity, state machine, conflict detection, cancellation policy logic
tc_paymentsStripe Connect bridge — PaymentIntents, refunds, webhook receiver, payouts
tc_geoSync hook from Drupal → PostGIS sidecar; bbox & nearest-N queries
tc_availabilityAvailability rules + overrides, slot resolver service
tc_membershipMembership entity, CSV import, email-domain verifier, guest pass tokens
tc_checkinAnonymous CheckIn entity, decay TTL, Busy-O-Meter heuristic
tc_reviewReview entity with moderation workflow
tc_apiCustom REST controllers (/api/courts/bbox, /api/bookings, /api/checkins, etc.)
tc_notificationsExpo Push API client, Postmark email client, queue workers

Facility (node bundle)

A physical site — park, club, or residence — that contains one or more Courts. Implemented as a custom node bundle so editors get the full Drupal node UX (revisions, moderation, scheduled publishing) without writing custom forms.

Machine name: node--facility · Drush: drush gen module tc_court

FieldTypeCard.ReqNotes
titlestring (built-in)1Facility name, e.g. "Goldman Tennis Center"
uuiduuid (auto)1Canonical cross-system ID
field_facility_ownerentity_reference → user1The owner account (null for unclaimed Discovery listings)
field_facility_typelist_string1park · club · school · private_home · resort
field_facility_addressaddress1Full postal address (country, admin area, locality, etc.)
field_facility_geogeofield (point)1WKT POINT; auto-synced to PostGIS sidecar on save
field_facility_geo_preciseboolean1If false, only show approximate pin until booking confirmed (private hosts)
field_facility_phonetelephone1Public contact
field_facility_websitelink1External URL (esp. for Discovery listings)
field_facility_hoursparagraph (hours)7One paragraph per day-of-week with open/close times
field_facility_photosmedia (image)20✓ (≥5)First image is the cover
field_facility_descriptiontext_long (rich)1Editorial copy, surface notes, etiquette
field_facility_amenitiesentity_reference → taxonomy:amenityParking, pro shop, restrooms, showers, etc.
field_facility_statuslist_string1draft · review · live · paused · removed
field_facility_verified_atdatetime1Last admin verification; surfaced as "Last verified MMM YYYY"
field_facility_external_systemlist_string1For Discovery mode: rectrac · activenet · courtreserve · other
field_facility_external_urllink1Deep link into external reservation system
Editorial workflow

All Facility nodes go through Drupal's Content Moderation workflow: draftreviewpublished. New private-host listings can't auto-publish — they require a moderator approval. Use workflows + content_moderation core modules.

Court (node bundle)

One bookable line item at a Facility — could be a single court or a court group offered together (e.g., "Courts 1–4 at GGP"). Booking mode is set per-court because facilities often run a mix.

FieldTypeCard.ReqNotes
titlestring1"Court 3", "Backyard Court", "Indoor Bubble 1"
field_court_facilityentity_reference → node:facility1Parent facility
field_court_surfaceentity_reference → taxonomy:surface1hard · clay · grass · carpet · indoor_hard
field_court_countinteger1Number of physical courts in this listing (≥1)
field_court_lightsboolean1Lit for night play
field_court_indoorboolean1Roofed / climate-controlled
field_court_ball_machineboolean1Ball machine available (free or rental)
field_court_booking_modelist_string1free_public · paid · member · discovery
field_court_base_price_centsinteger1Off-peak hourly price in cents (required if mode=paid)
field_court_peak_price_centsinteger1Peak hourly price; falls back to base if null
field_court_guest_price_centsinteger1Price for verified guests at a member-only court
field_court_min_durationinteger1Minimum booking minutes (default 60)
field_court_max_durationinteger1Maximum booking minutes (default 180)
field_court_slot_incrementinteger1Bookings must align to this many minutes (default 30)
field_court_cancellation_policylist_string1flexible · moderate · strict
field_court_approval_requiredlist_string1always · new_players · never
field_court_statuslist_string1draft · live · paused
field_court_external_urllink1Required when mode=discovery

Conditional field requirements (validation)

Implement these in tc_court/src/Plugin/Validation/Constraint/CourtBookingModeConstraint.php:

BaseFieldDefinition fragment (for reference)

tc_court/src/Plugin/Field/FieldType/example.php
$fields['field_court_booking_mode'] = BaseFieldDefinition::create('list_string')
  ->setLabel(t('Booking mode'))
  ->setRequired(TRUE)
  ->setSettings([
    'allowed_values' => [
      'free_public' => 'Free public (check-ins only)',
      'paid'        => 'Paid reservation',
      'member'      => 'Members only',
      'discovery'   => 'Discovery only (external)',
    ],
  ])
  ->setDefaultValue('discovery')
  ->setDisplayOptions('form', [
    'type'   => 'options_select',
    'weight' => 4,
  ])
  ->setDisplayConfigurable('form', TRUE)
  ->setDisplayConfigurable('view', TRUE);

Booking entity (custom content entity)

Bookings are not nodes. They're transactional records — high write volume, simple shape, no revisions needed. Implemented as a custom ContentEntityType (tc_booking) backed by its own table, with a state-machine workflow.

Entity definition

tc_booking/src/Entity/Booking.php
/**
 * @ContentEntityType(
 *   id = "tc_booking",
 *   label = @Translation("Booking"),
 *   base_table = "tc_booking",
 *   data_table = "tc_booking_field_data",
 *   entity_keys = {
 *     "id"   = "id",
 *     "uuid" = "uuid",
 *     "uid"  = "user_id",
 *   },
 *   handlers = {
 *     "storage"        = "Drupal\Core\Entity\Sql\SqlContentEntityStorage",
 *     "list_builder"   = "Drupal\tc_booking\BookingListBuilder",
 *     "access"         = "Drupal\tc_booking\BookingAccessControlHandler",
 *     "form" = {
 *       "default" = "Drupal\tc_booking\Form\BookingForm",
 *     },
 *   },
 *   admin_permission = "administer bookings",
 *   links = {
 *     "canonical" = "/admin/bookings/{tc_booking}",
 *     "collection" = "/admin/bookings",
 *   }
 * )
 */
class Booking extends ContentEntityBase { /* ... */ }

Fields

FieldTypeNotes
idinteger (PK)Auto-increment
uuiduuidCanonical cross-system ID
court_identity_reference → node:courtIndexed
user_identity_reference → userThe player
start_timedatetime (UTC)Indexed jointly with court_id for conflict detection
end_timedatetime (UTC)Computed from start + duration on save
duration_minutesinteger30, 60, 90, 120, …
price_centsintegerCourt price at time of booking (snapshot, not live lookup)
service_fee_centsintegerThe 12% platform fee snapshot
total_centsintegerprice + fee (stored for audit/payout accuracy)
currencystring(3)"USD" — future-proofing
statestate_machineSee diagram below
stripe_pi_idstringStripe PaymentIntent ID
stripe_charge_idstringSet when PI confirms
stripe_refund_idstringSet on refund
idempotency_keystring, unique indexGuards against double-creation on retry
cancellation_reasontextFree-form when state moves to canceled
createdtimestampAuto
changedtimestampAuto

State machine

     pending ──pay success──▶ confirmed ──slot end──▶ completed
        │                          │
        │                          ├──owner approves──▶ confirmed (manual)
        │                          │
        ├──10-min timeout─▶ canceled  ◀──user/owner cancels──┤
        │                                                    │
        ▼                                                    ▼
     canceled                                          refunded

Implemented with the state_machine contrib module. Workflow config in config/install/workflows.workflow.tc_booking.yml.

Conflict detection (the part that matters)

Concurrent booking attempts on the same court at overlapping times are the single hottest correctness path. The booking write path runs inside a row-locked transaction:

// tc_booking/src/Service/BookingWriter.php
public function create(array $input): Booking {
  $conn = Database::getConnection();
  return $conn->transaction()->execute(function () use ($input, $conn) {
    // 1. Row-lock the court for the duration of this transaction
    $conn->query(
      'SELECT 1 FROM node WHERE nid = :nid FOR UPDATE',
      [':nid' => $input['court_nid']]
    );

    // 2. Check for any overlapping booking in non-canceled state
    $exists = $conn->query(
      'SELECT 1 FROM tc_booking_field_data
       WHERE court_id = :nid
         AND state IN (\'pending\', \'confirmed\')
         AND NOT (end_time <= :start OR start_time >= :end)
       LIMIT 1',
      [
        ':nid'   => $input['court_nid'],
        ':start' => $input['start_time'],
        ':end'   => $input['end_time'],
      ]
    )->fetchField();

    if ($exists) {
      throw new BookingConflictException('Slot taken');
    }

    // 3. Create + return; PaymentIntent happens after this transaction commits
    return Booking::create($input);
  });
}
Why a database-level lock

Application-level locks (file, Redis, semaphore) all fail under Pantheon's multi-container deployment because a request on container A doesn't see locks on container B. SELECT … FOR UPDATE is the only correct primitive here.

Availability rules

Two entities work together: a recurring weekly template (tc_availability_rule) and one-off overrides (tc_availability_override). At query time, the slot resolver service merges them.

AvailabilityRule

FieldTypeNotes
id, uuidauto
court_identity_referenceIndexed
day_of_weekinteger 0–60 = Sunday, ISO-style
start_timetime HH:MMLocal time of the court's facility
end_timetime HH:MM
price_centsintegerOverrides the court's base/peak price for this window
is_peakbooleanDisplay hint; doesn't affect price math
effective_fromdateRule starts
effective_untildate, nullableOpen-ended if null

AvailabilityOverride

FieldTypeNotes
id, uuidauto
court_identity_reference
date_startdatetimeWindow start
date_enddatetimeWindow end (exclusive)
kindlist_stringblackout · open_extra · price
price_centsinteger, nullableOnly set when kind=price
reasonstring"Resurface week", "Tournament"

Slot resolver service

Drupal service tc_availability.resolver answers "what slots are available at this court on this date?" by walking rules + overrides + existing bookings. Cache results in Object Cache Pro keyed by court_uuid:YYYY-MM-DD with a 5-minute TTL.

CheckIn entity

Lightweight anonymous crowd reports that power the Busy-O-Meter on free public courts. Heavy write, low read, decays in 90 minutes.

FieldTypeNotes
id, uuidauto
court_identity_referenceIndexed
user_identity_reference, nullableAnonymous allowed
ip_hashstringSHA-256 of IP + daily salt; for rate limiting
crowd_levellist_stringempty · one_open · busy · full
createdtimestampIndexed

Rate limit: max 1 anonymous check-in per IP per court per 30 minutes. Hourly cron job purges check-ins older than 7 days (retain longer for the historical busyness model; we just don't need them online).

Membership entity

The relationship between a user and a club they belong to. Created by CSV import, email-domain auto-verify, admin approval, or OAuth handoff from club software.

FieldTypeNotes
id, uuidauto
facility_identity_reference → node:facilityMust be type=club
user_identity_reference → user
statuslist_stringpending · active · expired · revoked
verified_methodlist_stringcsv_import · email_domain · admin_approval · courtreserve_oauth
verified_atdatetime
expires_atdatetime, nullableAuto-rolls to expired via cron

Unique index on (facility_id, user_id) — one active membership per user per club.

Review entity

FieldTypeNotes
id, uuidauto
court_identity_reference
user_identity_reference
booking_identity_reference → tc_booking, nullableVerified review if set
rating_overallinteger 1–5Required
rating_surfaceinteger 1–5Optional sub-rating
rating_lightsinteger 1–5, nullableNull for courts without lights
bodytext_longPlain text, max 2,000 chars
photosmedia (image), ≤5
moderation_stateworkflowpending · approved · rejected
edited_untildatetimecreated + 24h; user can edit until then

User profile fields

Built on Drupal's core user entity. Add the following configurable fields (not base fields, so editors can manage them in the UI):

FieldTypeNotes
field_user_display_namestring, 50 charsPublic display; name stays as the login handle
field_user_photoimageCropped square; default avatar generated from initials
field_user_home_citystring, 100"San Francisco, CA"
field_user_ntrp_selfdecimal 2,1Range 1.0–7.0
field_user_ntrp_verifieddecimal 2,1, nullablePulled from linked USTA account
field_user_phonetelephoneE.164 format
field_user_phone_verifiedbooleanSet after SMS code confirmation
field_user_id_verifiedbooleanStripe Identity success
field_user_usta_numberstring, 12USTA member ID
field_user_handednesslist_stringleft · right · ambi
field_user_preferred_surfaceentity_reference → taxonomy:surfaceMulti
field_user_privacylist_stringpublic · friends · private (default friends)
field_user_stripe_customer_idstring, 64Set on first card add
field_user_referral_codestring, 12, uniqueGenerated on signup
field_user_expo_push_tokenstring, 200, multiOne per device

Roles

Drupal roles, in escalating order. Most are mutually compatible (a player can also be an owner).

RoleGranted byKey permissions
anonymousdefaultView public courts, list/read JSON:API
authenticatedsignup+ check in, save courts, view own profile
verified_playerphone + email verified+ create bookings, write reviews
ownersignup as host+ create draft Facility/Court, view own dashboard
owner_verifiedStripe Identity passed+ publish listings, receive payouts
moderatoradmin-grantedReview pending listings & reviews, ban users
adminadmin-grantedAll

Taxonomies

Surface (taxonomy:surface)

Configurable so editors can add seasonal surfaces. Fields beyond the standard taxonomy: field_surface_icon (image), field_surface_description (text).

Initial terms: hard, clay, grass, carpet, indoor_hard, indoor_clay.

Amenity (taxonomy:amenity)

Initial terms: lights, ball_machine, restrooms, showers, parking, pro_shop, locker_rooms, walkable_transit, water_fountain, court_attendant.

Add field_amenity_icon_name (string) — name of a Lucide icon, used by the frontend to render.

Why these are taxonomies and not list_string

Editors need to add and reorder these. Booking mode and cancellation policy are stable enough to live as list_string on the Court node.

JSON:API endpoints

JSON:API is enabled in core. Base path: https://api.thecourts.app/jsonapi. We override resource paths via jsonapi_extras to drop the bundle-prefix (so /jsonapi/courts instead of /jsonapi/node/court).

GET /jsonapi/courts
List courts. Supports filter, sort, sparse fieldsets, includes, pagination.

Example: paid courts within Bay Area, only the fields the map needs, 50 at a time:

curl 'https://api.thecourts.app/jsonapi/courts?\
filter[mode]=paid&\
filter[surface]=hard,clay&\
fields[node--court]=title,field_court_booking_mode,field_court_base_price_cents,field_court_facility&\
include=field_court_facility&\
page[limit]=50'

Response shape (truncated):

{
  "data": [
    {
      "type": "node--court",
      "id": "f8a3...",
      "attributes": {
        "title": "Goldman Tennis Center Court 3",
        "field_court_booking_mode": "paid",
        "field_court_base_price_cents": 1800
      },
      "relationships": {
        "field_court_facility": {
          "data": { "type": "node--facility", "id": "9c12..." }
        }
      }
    }
  ],
  "included": [
    {
      "type": "node--facility",
      "id": "9c12...",
      "attributes": {
        "title": "Lisa & Douglas Goldman Tennis Center",
        "field_facility_geo": { "lon": -122.4565, "lat": 37.7679 }
      }
    }
  ],
  "links": {
    "next": "https://api.thecourts.app/jsonapi/courts?page[offset]=50&page[limit]=50"
  }
}
GET /jsonapi/courts/{uuid}
Single court with deep relationships
curl 'https://api.thecourts.app/jsonapi/courts/f8a3...?\
include=field_court_facility,field_court_facility.field_facility_owner,field_court_surface'
GET /jsonapi/facilities/{uuid}
Single facility with all its courts
curl 'https://api.thecourts.app/jsonapi/facilities/9c12...?\
include=field_court_facility.reverse'

Filter idioms we use a lot

GoalFilter
Only live courtsfilter[status]=1&filter[field_court_status]=live
Mode in listfilter[field_court_booking_mode]=paid,member
Has lightsfilter[field_court_lights]=1
By facilityfilter[field_court_facility.id]=9c12...
Sort by rating descsort=-field_facility_rating_avg

Custom REST surface

Anywhere JSON:API can't express the query cleanly — geo bbox, availability resolution, transactional booking — we ship a custom REST controller under /api/*.

GET /api/courts/bbox
Map viewport query. Hits PostGIS sidecar; returns lightweight markers.
curl 'https://api.thecourts.app/api/courts/bbox?\
sw_lat=37.70&sw_lng=-122.51&\
ne_lat=37.83&ne_lng=-122.36&\
modes=free,paid,member,discovery&\
surfaces=hard,clay&\
limit=200'
{
  "data": [
    {
      "uuid": "f8a3...",
      "name": "Golden Gate Park Tennis Courts",
      "facility_uuid": "9c12...",
      "lat": 37.7694,
      "lng": -122.4862,
      "mode": "free",
      "surface": "hard",
      "court_count": 16,
      "rating_avg": 4.6,
      "review_count": 312,
      "thumb_url": "https://media.thecourts.app/.../w_240.jpg"
    }
  ],
  "meta": {
    "total": 18,
    "bbox": [37.70, -122.51, 37.83, -122.36],
    "took_ms": 14
  }
}
GET /api/courts/{uuid}/availability
Resolve open slots for a date + duration
curl 'https://api.thecourts.app/api/courts/f8a3.../availability?\
date=2026-05-22&duration=60'
{
  "data": {
    "court_uuid": "f8a3...",
    "date": "2026-05-22",
    "timezone": "America/Los_Angeles",
    "duration_minutes": 60,
    "slots": [
      { "start": "06:00", "end": "07:00", "available": true,  "price_cents": 1800 },
      { "start": "07:00", "end": "08:00", "available": true,  "price_cents": 1800 },
      { "start": "08:00", "end": "09:00", "available": false, "reason": "booked" },
      { "start": "17:00", "end": "18:00", "available": true,  "price_cents": 2400, "is_peak": true }
    ]
  }
}
POST /api/bookings
Create a booking. Wraps Stripe PaymentIntent creation. Requires auth + Idempotency-Key.
curl -X POST 'https://api.thecourts.app/api/bookings' \
  -H 'Authorization: Bearer eyJ...' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 5f3a7c9d-...' \
  -d '{
    "court_uuid": "f8a3...",
    "start_time": "2026-05-22T18:00:00-07:00",
    "duration_minutes": 60,
    "payment_method_id": "pm_1Q..."
  }'
{
  "data": {
    "uuid": "b9e2...",
    "state": "pending",
    "court_uuid": "f8a3...",
    "start_time": "2026-05-22T18:00:00-07:00",
    "end_time":   "2026-05-22T19:00:00-07:00",
    "price_cents": 2400,
    "service_fee_cents": 288,
    "total_cents": 2688,
    "stripe_client_secret": "pi_3Q..._secret_..."
  }
}

Client confirms the PaymentIntent on-device (Stripe Elements / PaymentSheet). The Stripe webhook then moves the booking from pendingconfirmed.

POST /api/bookings/{uuid}/cancel
Cancel + refund per the court's cancellation policy
POST /api/checkins
Anonymous or authenticated crowd report
curl -X POST 'https://api.thecourts.app/api/checkins' \
  -H 'Content-Type: application/json' \
  -d '{
    "court_uuid": "f8a3...",
    "crowd_level": "one_open"
  }'
GET /api/me
Current user + memberships + saved courts + active bookings (one-call hydrate for the mobile app)

Custom controller skeleton

tc_api/src/Controller/BboxController.php
namespace Drupal\tc_api\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\tc_geo\GeoQueryService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;

class BboxController extends ControllerBase {

  public function __construct(private GeoQueryService $geo) {}

  public static function create($container): self {
    return new self($container->get('tc_geo.query'));
  }

  public function __invoke(Request $request): JsonResponse {
    $bbox = [
      'sw_lat' => (float) $request->query->get('sw_lat'),
      'sw_lng' => (float) $request->query->get('sw_lng'),
      'ne_lat' => (float) $request->query->get('ne_lat'),
      'ne_lng' => (float) $request->query->get('ne_lng'),
    ];
    $filters = [
      'modes'    => explode(',', $request->query->get('modes', '')),
      'surfaces' => explode(',', $request->query->get('surfaces', '')),
    ];

    $results = $this->geo->bboxQuery($bbox, $filters, (int) $request->query->get('limit', 200));

    return new JsonResponse([
      'data' => $results['items'],
      'meta' => [
        'total'  => $results['total'],
        'bbox'   => array_values($bbox),
        'took_ms' => $results['took_ms'],
      ],
    ], 200, [
      'Cache-Control'   => 'public, max-age=60',
      'X-Tc-Cache-Tags' => 'court_list',
    ]);
  }
}

Auth & OAuth

Drupal's simple_oauth module issues access + refresh tokens. The mobile app uses native Sign in with Apple / Google, which exchanges the platform token for a Drupal-issued access token via /oauth/token.

Token endpoint

POST /oauth/token
OAuth2 token grant — supports password, refresh_token, and a custom apple_id_token grant
// Request — refresh
{
  "grant_type": "refresh_token",
  "client_id": "thecourts-mobile",
  "refresh_token": "def502..."
}

// Response
{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "eyJ0eXAi...",
  "refresh_token": "def502..."
}

Custom grant for Apple Sign-In

Add a custom grant class so the mobile app can exchange an Apple-issued id_token for a Drupal token in one call. See tc_api/src/OAuth/AppleIdTokenGrant.php.

Token storage on clients

Stripe webhooks

POST /webhooks/stripe
Stripe webhook receiver — verifies signature, dispatches to handler
// tc_payments/src/Controller/StripeWebhookController.php
public function __invoke(Request $request): Response {
  $payload = $request->getContent();
  $sig = $request->headers->get('Stripe-Signature');

  try {
    $event = \Stripe\Webhook::constructEvent($payload, $sig, $this->config->get('webhook_secret'));
  } catch (\Exception $e) {
    return new Response('Invalid signature', 400);
  }

  // Idempotency — store event ID for 30 days
  if ($this->processedEvents->seen($event->id)) {
    return new Response('OK (duplicate)', 200);
  }

  match ($event->type) {
    'payment_intent.succeeded'         => $this->handler->onPaymentSuccess($event),
    'payment_intent.payment_failed'    => $this->handler->onPaymentFailure($event),
    'charge.refunded'                  => $this->handler->onRefund($event),
    'account.updated'                  => $this->handler->onConnectAccountUpdate($event),
    'identity.verification_session.verified' => $this->handler->onIdentityVerified($event),
    default => null, // ignore
  };

  $this->processedEvents->markSeen($event->id);
  return new Response('OK', 200);
}

Stripe events we listen to: payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, charge.dispute.created, account.updated, identity.verification_session.verified, payout.paid.

Next.js fetching patterns

Next.js 14 App Router. Three patterns by route type:

RouteStrategyRevalidate
/courts/[state]/[city]/[slug]SSG + ISR3600s (1h)
/mapSSR shell, client-side map
/me/* (logged-in)SSR (no cache)0
/host/* (owner)SSR (no cache)0
Static pages (about, FAQ)SSG

Server-side fetcher

apps/web/lib/api.ts
const API = process.env.NEXT_PUBLIC_API_URL!; // https://api.thecourts.app

export async function getCourt(uuid: string) {
  const url = `${API}/jsonapi/courts/${uuid}?include=field_court_facility,field_court_surface`;
  const res = await fetch(url, {
    next: { revalidate: 3600, tags: [`court:${uuid}`] }
  });
  if (!res.ok) throw new Error(`Failed: ${res.status}`);
  const json = await res.json();
  return normalizeCourt(json);
}

export async function getCourtsInBbox(bbox: BBox, filters: Filters) {
  const params = new URLSearchParams({
    sw_lat: bbox.sw_lat.toString(),
    sw_lng: bbox.sw_lng.toString(),
    ne_lat: bbox.ne_lat.toString(),
    ne_lng: bbox.ne_lng.toString(),
    modes: filters.modes.join(','),
    surfaces: filters.surfaces.join(','),
  });
  const res = await fetch(`${API}/api/courts/bbox?${params}`, {
    next: { revalidate: 60, tags: ['court_list'] }
  });
  return res.json();
}

Cache invalidation from Drupal

When a court is saved in Drupal, the Pantheon Advanced Page Cache module emits cache tags. The Next.js layer subscribes via a small webhook handler that calls revalidateTag():

apps/web/app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
import crypto from 'crypto';

export async function POST(req: NextRequest) {
  // Verify Drupal signature
  const sig = req.headers.get('x-tc-signature') ?? '';
  const body = await req.text();
  const expected = crypto
    .createHmac('sha256', process.env.REVALIDATE_SECRET!)
    .update(body)
    .digest('hex');
  if (sig !== expected) return new Response('Forbidden', { status: 403 });

  const { tags } = JSON.parse(body) as { tags: string[] };
  for (const tag of tags) revalidateTag(tag);
  return Response.json({ ok: true, count: tags.length });
}

Corresponding Drupal hook in tc_api/tc_api.module:

function tc_api_entity_update(EntityInterface $entity) {
  $tags = $entity->getCacheTagsToInvalidate();
  \Drupal::service('tc_api.next_revalidator')->emit($tags);
}

Mapbox integration

Mapbox GL JS v3 on the web side. The map is a client component because GL JS is browser-only; it talks to the same /api/courts/bbox endpoint as the mobile app.

apps/web/components/Map.tsx
'use client';

import mapboxgl from 'mapbox-gl';
import { useEffect, useRef, useState } from 'react';
import { getCourtsInBbox } from '@/lib/api';
import 'mapbox-gl/dist/mapbox-gl.css';

mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN!;

export function Map({ initialCenter }: { initialCenter: [number, number] }) {
  const containerRef = useRef(null);
  const mapRef = useRef(null);
  const markersRef = useRef>({});

  useEffect(() => {
    if (!containerRef.current) return;
    const map = new mapboxgl.Map({
      container: containerRef.current,
      style: 'mapbox://styles/thecourts/clz...',  // custom style
      center: initialCenter,
      zoom: 11,
    });
    mapRef.current = map;

    map.on('moveend', () => loadViewport(map));
    map.once('load',   () => loadViewport(map));
    return () => map.remove();
  }, [initialCenter]);

  async function loadViewport(map: mapboxgl.Map) {
    const b = map.getBounds()!;
    const courts = await getCourtsInBbox({
      sw_lat: b.getSouth(), sw_lng: b.getWest(),
      ne_lat: b.getNorth(), ne_lng: b.getEast(),
    }, currentFilters());

    // Diff + reconcile markers — avoid full re-render
    syncMarkers(map, markersRef.current, courts.data);
  }

  return 
; }

Cost guardrails

Booking flow integration

Bookings hit /api/bookings on Drupal and get back a Stripe client_secret to confirm in the browser/app.

apps/web/app/courts/[uuid]/book/page.tsx
'use client';

import { loadStripe } from '@stripe/stripe-js';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY!);

export default function BookPage({ params, searchParams }) {
  const [clientSecret, setClientSecret] = useState();

  async function startBooking() {
    const res = await fetch('/api/proxy/bookings', {  // proxies to Drupal with auth
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': crypto.randomUUID(),
      },
      body: JSON.stringify({
        court_uuid: params.uuid,
        start_time: searchParams.start,
        duration_minutes: parseInt(searchParams.duration, 10),
      }),
    });
    const { data } = await res.json();
    setClientSecret(data.stripe_client_secret);
  }

  if (!clientSecret) return ;

  return (
    
      
    
  );
}

PostGIS sidecar bridge

Drupal's geofield works for storage but is slow for bbox queries at scale (it queries MariaDB with a bounding-box predicate that doesn't use spatial indexes well). Our solution: a tiny PostgreSQL database with PostGIS that mirrors just the geo-relevant columns.

Schema

CREATE EXTENSION IF NOT EXISTS postgis;

CREATE TABLE court_locations (
  uuid           UUID PRIMARY KEY,
  facility_uuid  UUID NOT NULL,
  name           TEXT NOT NULL,
  thumb_url      TEXT,
  mode           TEXT NOT NULL,
  surface        TEXT NOT NULL,
  court_count    SMALLINT NOT NULL DEFAULT 1,
  rating_avg     NUMERIC(2,1),
  review_count   INTEGER NOT NULL DEFAULT 0,
  has_lights     BOOLEAN NOT NULL DEFAULT false,
  indoor         BOOLEAN NOT NULL DEFAULT false,
  geom           GEOGRAPHY(POINT, 4326) NOT NULL,
  status         TEXT NOT NULL DEFAULT 'live',
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_court_locations_geom ON court_locations USING GIST (geom);
CREATE INDEX idx_court_locations_mode_surface ON court_locations (mode, surface);
CREATE INDEX idx_court_locations_status ON court_locations (status);

Sync hook (Drupal → Postgres)

// tc_geo/tc_geo.module
function tc_geo_node_presave(NodeInterface $node) {
  if ($node->bundle() !== 'court' || $node->isNew()) return;
  // run on update only — Pantheon-friendly because we're not blocking on Postgres
  \Drupal::service('advancedqueue.processor')
    ->enqueue('tc_geo_sync', ['nid' => $node->id()]);
}

A queue worker pulls the entry and writes an upsert to Postgres:

INSERT INTO court_locations (uuid, facility_uuid, name, mode, surface, geom, ...)
VALUES ($1, $2, $3, $4, $5, ST_MakePoint($6, $7)::geography, ...)
ON CONFLICT (uuid) DO UPDATE
  SET name        = EXCLUDED.name,
      mode        = EXCLUDED.mode,
      surface     = EXCLUDED.surface,
      geom        = EXCLUDED.geom,
      updated_at  = now();

Bbox query

SELECT uuid, facility_uuid, name, thumb_url, mode, surface,
       court_count, rating_avg, review_count, has_lights,
       ST_Y(geom::geometry) AS lat,
       ST_X(geom::geometry) AS lng
FROM court_locations
WHERE status = 'live'
  AND mode = ANY($1)
  AND surface = ANY($2)
  AND geom && ST_MakeEnvelope($3, $4, $5, $6, 4326)
LIMIT 200;

P95 under 30ms with GIST index on a dataset of 250k courts. Recommended host: Crunchy Bridge or AWS RDS in the same region as Pantheon (us-west-2 if launch metros are CA).

Pantheon deployment notes

File-system constraints

Pantheon's application containers have a read-only filesystem (except /files, which is shared but limited to 100GB). All user-uploaded media — court photos, owner verification docs — goes to S3 via the s3fs module.

settings.php
// S3 keys come from Pantheon environment variables
$settings['s3fs.access_key'] = $_ENV['AWS_ACCESS_KEY_ID'];
$settings['s3fs.secret_key'] = $_ENV['AWS_SECRET_ACCESS_KEY'];
$settings['s3fs.use_https']  = TRUE;

$config['s3fs.settings']['bucket'] = $_ENV['S3FS_BUCKET'];
$config['s3fs.settings']['region'] = 'us-west-2';

// Make S3 the public scheme so image styles cache there
$settings['file_public_path'] = 's3://files';

Object Cache Pro

Pantheon's Redis-backed object cache. Configure in settings.pantheon.php (already shipped). Cache hit rate > 85% on entity reads is the target. Verify with:

terminus drush thecourts.live cache:stats

Long-running jobs

Pantheon caps individual HTTP requests at 120 seconds. Anything that might run longer (bulk imports, payouts, CSV exports) must run via the Advanced Queue module's worker, NOT inline. Workers run as scheduled jobs:

# In Pantheon's scheduled jobs UI:
drush advancedqueue:process tc_booking_settle    --time-limit=90    every 5 min
drush advancedqueue:process tc_geo_sync          --time-limit=90    every 1 min
drush advancedqueue:process tc_notify_push       --time-limit=90    every 1 min
drush advancedqueue:process tc_payment_payout    --time-limit=90    every 1 hour

Multidev workflow

Composer-managed

Use the pantheon-systems/drupal-composer-managed upstream. Custom modules live in web/modules/custom/. Never edit Drupal core or contrib modules in place; patch via cweagans/composer-patches.

Seeding & migration

OSM ingestion

Pull leisure=pitch + sport=tennis features from OpenStreetMap via the Overpass API. Output GeoJSON → migration source plugin → Facility + Court nodes in draft state for editorial review.

# One-time bootstrap per metro
drush mim tc_seed_osm_bay_area
drush mim tc_seed_osm_san_diego
drush mim tc_seed_osm_los_angeles

Municipal CSV import

SF, San Diego, LA all publish open-data CSVs of parks and facilities. Drupal Migrate API with a CSV source plugin loads them and resolves duplicates against OSM by name+bbox.

Owner self-listing

Owners create listings through the dashboard UI (no migration needed) — that's the long-tail acquisition path.

Permissions matrix

Actionanonauthverifiedownerowner_verifiedmodadmin
View live courts
Anonymous check-in
Save courts
Create booking
Write review
Create draft Facility
Publish listing (live)
Receive payouts
Moderate listings
Ban users
Admin everything

Composer manifest

composer.json (relevant sections)
{
  "require": {
    "php": "^8.3",
    "composer/installers": "^2.3",
    "drupal/core-composer-scaffold": "^11.0",
    "drupal/core-recommended": "^11.0",
    "drush/drush": "^13.0",
    "pantheon-systems/drupal-integrations": "^11",

    "drupal/simple_oauth": "^6.0",
    "drupal/social_auth_apple": "^4.0",
    "drupal/social_auth_google": "^4.0",
    "drupal/jsonapi_extras": "^3.25",
    "drupal/jsonapi_resources": "^1.1",
    "drupal/geofield": "^1.59",
    "drupal/address": "^2.0",
    "drupal/smart_date": "^4.1",
    "drupal/paragraphs": "^1.18",
    "drupal/s3fs": "^3.6",
    "drupal/advancedqueue": "^1.4",
    "drupal/state_machine": "^1.10",
    "drupal/pantheon_advanced_page_cache": "^2.2",

    "stripe/stripe-php": "^15.0",
    "aws/aws-sdk-php": "^3.300"
  }
}

Drush cheatsheet

# Daily ops
terminus drush thecourts.live -- cache:rebuild
terminus drush thecourts.live -- core:status
terminus drush thecourts.live -- watchdog:show --severity=error --count=50

# Config sync (deploys)
terminus drush thecourts.dev -- config:export -y
terminus drush thecourts.test -- config:import -y --partial

# Run a queue manually
terminus drush thecourts.live -- advancedqueue:process tc_geo_sync --time-limit=60

# Re-index search (Solr/Algolia)
terminus drush thecourts.live -- search-api:reindex thecourts_index
terminus drush thecourts.live -- search-api:index thecourts_index

# Generate a one-time admin login link
terminus drush thecourts.live -- user:login

# Seed test data
terminus drush thecourts.dev -- mim tc_seed_osm_bay_area --update
Hand-off checklist

For the engineer picking this up:

  1. Read the PRD Sections 10–13 first — they set the architecture context this doc builds on.
  2. Spin up a Pantheon Dev site from the drupal-composer-managed upstream.
  3. Enable core + contrib modules; run config:import from the repo's config/sync.
  4. Implement tc_court first (Facility + Court bundles). Verify in the admin UI before writing any API code.
  5. Add JSON:API Extras config to flatten resource paths.
  6. Build tc_geo + the PostGIS sidecar; verify bbox returns realistic results.
  7. Stand up tc_api with the custom endpoints listed above.
  8. Wire tc_booking + tc_payments with Stripe test mode; run the conflict-detection unit tests under concurrent load.
  9. Then start the Next.js app against the staging Drupal environment.
The Courts · Engineering hand-off v0.1 · ryan@ornelas.org