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.
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
- Drupal owns the content layer. Every entity has a canonical Drupal node/entity record. Anything else (PostGIS row, Stripe object, Algolia document) is a derived projection kept in sync via entity hooks.
- JSON:API is the default read path. Custom REST exists only where JSON:API can't reasonably express the query (bbox geo, availability resolver, transactional booking writes).
- UUIDs are the canonical identifier across systems. Auto-increment IDs stay inside Drupal; the PostGIS sidecar, Stripe metadata, and Next.js URLs all use UUIDs.
- Permissions are role-driven in Drupal. The mobile/web apps never need their own auth model — they map directly to Drupal roles via OAuth.
- All writes are idempotent. Every mutating endpoint accepts an
Idempotency-Keyheader and stores the result for 24 hours so retries don't double-charge or double-book.
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
| Module | Version | Purpose |
|---|---|---|
simple_oauth | ^6.0 | OAuth2 + JWT issuer for the mobile/web apps |
social_auth_apple, social_auth_google | ^4.0 | Sign in with Apple / Google |
jsonapi_extras | ^3.25 | Sparse-field defaults, virtual fields, override resource paths |
jsonapi_resources | ^1.1 | Plugin base for custom JSON:API resources (geo bbox, availability) |
geofield | ^1.x | Geo field type + Geo PHP integration |
address | ^2.0 | Proper postal address field with country logic |
smart_date | ^4.0 | Better datetime widgets for availability rules |
paragraphs | ^1.18 | Flexible nested content (hours, court groups) |
s3fs | ^3.x | S3-backed file system — required on Pantheon (read-only FS) |
advancedqueue | ^1.x | Background jobs (push delivery, Stripe webhooks, payouts) |
state_machine | ^1.x | Booking state machine |
pantheon_advanced_page_cache | ^2.x | Cache-tag-aware purging at the Pantheon edge |
maillog (dev only) | ^1.x | Capture outbound mail in Dev/Test |
Custom modules to build
| Module | Owns |
|---|---|
tc_court | Facility + Court node bundles, related field config, view modes |
tc_booking | Booking content entity, state machine, conflict detection, cancellation policy logic |
tc_payments | Stripe Connect bridge — PaymentIntents, refunds, webhook receiver, payouts |
tc_geo | Sync hook from Drupal → PostGIS sidecar; bbox & nearest-N queries |
tc_availability | Availability rules + overrides, slot resolver service |
tc_membership | Membership entity, CSV import, email-domain verifier, guest pass tokens |
tc_checkin | Anonymous CheckIn entity, decay TTL, Busy-O-Meter heuristic |
tc_review | Review entity with moderation workflow |
tc_api | Custom REST controllers (/api/courts/bbox, /api/bookings, /api/checkins, etc.) |
tc_notifications | Expo 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
| Field | Type | Card. | Req | Notes |
|---|---|---|---|---|
title | string (built-in) | 1 | ✓ | Facility name, e.g. "Goldman Tennis Center" |
uuid | uuid (auto) | 1 | ✓ | Canonical cross-system ID |
field_facility_owner | entity_reference → user | 1 | — | The owner account (null for unclaimed Discovery listings) |
field_facility_type | list_string | 1 | ✓ | park · club · school · private_home · resort |
field_facility_address | address | 1 | ✓ | Full postal address (country, admin area, locality, etc.) |
field_facility_geo | geofield (point) | 1 | ✓ | WKT POINT; auto-synced to PostGIS sidecar on save |
field_facility_geo_precise | boolean | 1 | — | If false, only show approximate pin until booking confirmed (private hosts) |
field_facility_phone | telephone | 1 | — | Public contact |
field_facility_website | link | 1 | — | External URL (esp. for Discovery listings) |
field_facility_hours | paragraph (hours) | 7 | — | One paragraph per day-of-week with open/close times |
field_facility_photos | media (image) | 20 | ✓ (≥5) | First image is the cover |
field_facility_description | text_long (rich) | 1 | — | Editorial copy, surface notes, etiquette |
field_facility_amenities | entity_reference → taxonomy:amenity | ∞ | — | Parking, pro shop, restrooms, showers, etc. |
field_facility_status | list_string | 1 | ✓ | draft · review · live · paused · removed |
field_facility_verified_at | datetime | 1 | — | Last admin verification; surfaced as "Last verified MMM YYYY" |
field_facility_external_system | list_string | 1 | — | For Discovery mode: rectrac · activenet · courtreserve · other |
field_facility_external_url | link | 1 | — | Deep link into external reservation system |
All Facility nodes go through Drupal's Content Moderation workflow: draft → review → published. 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.
| Field | Type | Card. | Req | Notes |
|---|---|---|---|---|
title | string | 1 | ✓ | "Court 3", "Backyard Court", "Indoor Bubble 1" |
field_court_facility | entity_reference → node:facility | 1 | ✓ | Parent facility |
field_court_surface | entity_reference → taxonomy:surface | 1 | ✓ | hard · clay · grass · carpet · indoor_hard |
field_court_count | integer | 1 | ✓ | Number of physical courts in this listing (≥1) |
field_court_lights | boolean | 1 | — | Lit for night play |
field_court_indoor | boolean | 1 | — | Roofed / climate-controlled |
field_court_ball_machine | boolean | 1 | — | Ball machine available (free or rental) |
field_court_booking_mode | list_string | 1 | ✓ | free_public · paid · member · discovery |
field_court_base_price_cents | integer | 1 | — | Off-peak hourly price in cents (required if mode=paid) |
field_court_peak_price_cents | integer | 1 | — | Peak hourly price; falls back to base if null |
field_court_guest_price_cents | integer | 1 | — | Price for verified guests at a member-only court |
field_court_min_duration | integer | 1 | — | Minimum booking minutes (default 60) |
field_court_max_duration | integer | 1 | — | Maximum booking minutes (default 180) |
field_court_slot_increment | integer | 1 | — | Bookings must align to this many minutes (default 30) |
field_court_cancellation_policy | list_string | 1 | — | flexible · moderate · strict |
field_court_approval_required | list_string | 1 | — | always · new_players · never |
field_court_status | list_string | 1 | ✓ | draft · live · paused |
field_court_external_url | link | 1 | — | Required when mode=discovery |
Conditional field requirements (validation)
Implement these in tc_court/src/Plugin/Validation/Constraint/CourtBookingModeConstraint.php:
- If
field_court_booking_mode=paid,field_court_base_price_centsmust be ≥ 100 (i.e., ≥ $1). - If
field_court_booking_mode=member, parent Facility must havefield_facility_type=club. - If
field_court_booking_mode=discovery,field_court_external_urlis required.
BaseFieldDefinition fragment (for reference)
$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
/**
* @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
| Field | Type | Notes |
|---|---|---|
id | integer (PK) | Auto-increment |
uuid | uuid | Canonical cross-system ID |
court_id | entity_reference → node:court | Indexed |
user_id | entity_reference → user | The player |
start_time | datetime (UTC) | Indexed jointly with court_id for conflict detection |
end_time | datetime (UTC) | Computed from start + duration on save |
duration_minutes | integer | 30, 60, 90, 120, … |
price_cents | integer | Court price at time of booking (snapshot, not live lookup) |
service_fee_cents | integer | The 12% platform fee snapshot |
total_cents | integer | price + fee (stored for audit/payout accuracy) |
currency | string(3) | "USD" — future-proofing |
state | state_machine | See diagram below |
stripe_pi_id | string | Stripe PaymentIntent ID |
stripe_charge_id | string | Set when PI confirms |
stripe_refund_id | string | Set on refund |
idempotency_key | string, unique index | Guards against double-creation on retry |
cancellation_reason | text | Free-form when state moves to canceled |
created | timestamp | Auto |
changed | timestamp | Auto |
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);
});
}
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
| Field | Type | Notes |
|---|---|---|
id, uuid | auto | |
court_id | entity_reference | Indexed |
day_of_week | integer 0–6 | 0 = Sunday, ISO-style |
start_time | time HH:MM | Local time of the court's facility |
end_time | time HH:MM | |
price_cents | integer | Overrides the court's base/peak price for this window |
is_peak | boolean | Display hint; doesn't affect price math |
effective_from | date | Rule starts |
effective_until | date, nullable | Open-ended if null |
AvailabilityOverride
| Field | Type | Notes |
|---|---|---|
id, uuid | auto | |
court_id | entity_reference | |
date_start | datetime | Window start |
date_end | datetime | Window end (exclusive) |
kind | list_string | blackout · open_extra · price |
price_cents | integer, nullable | Only set when kind=price |
reason | string | "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.
| Field | Type | Notes |
|---|---|---|
id, uuid | auto | |
court_id | entity_reference | Indexed |
user_id | entity_reference, nullable | Anonymous allowed |
ip_hash | string | SHA-256 of IP + daily salt; for rate limiting |
crowd_level | list_string | empty · one_open · busy · full |
created | timestamp | Indexed |
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.
| Field | Type | Notes |
|---|---|---|
id, uuid | auto | |
facility_id | entity_reference → node:facility | Must be type=club |
user_id | entity_reference → user | |
status | list_string | pending · active · expired · revoked |
verified_method | list_string | csv_import · email_domain · admin_approval · courtreserve_oauth |
verified_at | datetime | |
expires_at | datetime, nullable | Auto-rolls to expired via cron |
Unique index on (facility_id, user_id) — one active membership per user per club.
Review entity
| Field | Type | Notes |
|---|---|---|
id, uuid | auto | |
court_id | entity_reference | |
user_id | entity_reference | |
booking_id | entity_reference → tc_booking, nullable | Verified review if set |
rating_overall | integer 1–5 | Required |
rating_surface | integer 1–5 | Optional sub-rating |
rating_lights | integer 1–5, nullable | Null for courts without lights |
body | text_long | Plain text, max 2,000 chars |
photos | media (image), ≤5 | |
moderation_state | workflow | pending · approved · rejected |
edited_until | datetime | created + 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):
| Field | Type | Notes |
|---|---|---|
field_user_display_name | string, 50 chars | Public display; name stays as the login handle |
field_user_photo | image | Cropped square; default avatar generated from initials |
field_user_home_city | string, 100 | "San Francisco, CA" |
field_user_ntrp_self | decimal 2,1 | Range 1.0–7.0 |
field_user_ntrp_verified | decimal 2,1, nullable | Pulled from linked USTA account |
field_user_phone | telephone | E.164 format |
field_user_phone_verified | boolean | Set after SMS code confirmation |
field_user_id_verified | boolean | Stripe Identity success |
field_user_usta_number | string, 12 | USTA member ID |
field_user_handedness | list_string | left · right · ambi |
field_user_preferred_surface | entity_reference → taxonomy:surface | Multi |
field_user_privacy | list_string | public · friends · private (default friends) |
field_user_stripe_customer_id | string, 64 | Set on first card add |
field_user_referral_code | string, 12, unique | Generated on signup |
field_user_expo_push_token | string, 200, multi | One per device |
Roles
Drupal roles, in escalating order. Most are mutually compatible (a player can also be an owner).
| Role | Granted by | Key permissions |
|---|---|---|
anonymous | default | View public courts, list/read JSON:API |
authenticated | signup | + check in, save courts, view own profile |
verified_player | phone + email verified | + create bookings, write reviews |
owner | signup as host | + create draft Facility/Court, view own dashboard |
owner_verified | Stripe Identity passed | + publish listings, receive payouts |
moderator | admin-granted | Review pending listings & reviews, ban users |
admin | admin-granted | All |
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).
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"
}
}
curl 'https://api.thecourts.app/jsonapi/courts/f8a3...?\
include=field_court_facility,field_court_facility.field_facility_owner,field_court_surface'
curl 'https://api.thecourts.app/jsonapi/facilities/9c12...?\
include=field_court_facility.reverse'
Filter idioms we use a lot
| Goal | Filter |
|---|---|
| Only live courts | filter[status]=1&filter[field_court_status]=live |
| Mode in list | filter[field_court_booking_mode]=paid,member |
| Has lights | filter[field_court_lights]=1 |
| By facility | filter[field_court_facility.id]=9c12... |
| Sort by rating desc | sort=-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/*.
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
}
}
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 }
]
}
}
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 pending → confirmed.
curl -X POST 'https://api.thecourts.app/api/checkins' \
-H 'Content-Type: application/json' \
-d '{
"court_uuid": "f8a3...",
"crowd_level": "one_open"
}'
Custom controller skeleton
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
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
- Web: HTTP-only secure cookies, bridged from OAuth via
/auth/callbackon the Next.js side. - Mobile: refresh token in iOS Keychain / Android Keystore via Expo SecureStore. Access token in memory only.
Stripe webhooks
// 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:
| Route | Strategy | Revalidate |
|---|---|---|
/courts/[state]/[city]/[slug] | SSG + ISR | 3600s (1h) |
/map | SSR 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
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():
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.
'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
- Tile cache:
cache-control: max-age=86400on tile responses (Mapbox already does this). - Bbox query debounce: 300ms on
moveendbefore firing a fresh fetch. - Cluster on the client when zoom < 13 to keep marker count under ~150.
- Alert on Mapbox spend at 70% of monthly cap; pause non-essential features at 90%.
Booking flow integration
Bookings hit /api/bookings on Drupal and get back a Stripe client_secret to confirm in the browser/app.
'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.
// 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
- Trunk:
main→ Pantheon Dev environment auto-deploys. - Pull requests: each PR auto-creates a Multidev with name
pr-{number}. PR closed → Multidev deleted. - Promote to Test:
terminus env:deploy thecourts.test --sync-content --updatedb --cc. - Promote to Live: same with
--notefor the deploy log; gated behind a GitHub Action approval step.
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
| Action | anon | auth | verified | owner | owner_verified | mod | admin |
|---|---|---|---|---|---|---|---|
| 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
{
"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
For the engineer picking this up:
- Read the PRD Sections 10–13 first — they set the architecture context this doc builds on.
- Spin up a Pantheon Dev site from the
drupal-composer-managedupstream. - Enable core + contrib modules; run
config:importfrom the repo'sconfig/sync. - Implement
tc_courtfirst (Facility + Court bundles). Verify in the admin UI before writing any API code. - Add JSON:API Extras config to flatten resource paths.
- Build
tc_geo+ the PostGIS sidecar; verify bbox returns realistic results. - Stand up
tc_apiwith the custom endpoints listed above. - Wire
tc_booking+tc_paymentswith Stripe test mode; run the conflict-detection unit tests under concurrent load. - Then start the Next.js app against the staging Drupal environment.