Locations (Spaces) — Behavior
State
Single boolean is_active:
| State | Meaning |
|---|---|
is_active=true | Listed in findAll, selectable at session-create. Counts against the maxSpaces tier limit. |
is_active=false | Hidden from findAll. Existing sessions referencing it remain valid. |
DELETE /locations/:id performs update set is_active=false, never DELETE FROM.
Invariants
- Org-direct ownership —
locations.organization_idis the boundary. Every read/write filters on it (and(eq(locations.id, id), eq(locations.organizationId, orgId))). - No hard delete — deactivation only. Historical sessions retain a valid FK.
- Capacity gates sessions only with the
spacesflag on — then a session’scapacitymay not exceedlocation.capacity(see Spaces flag). Flag off:location.capacityis informational. Either way, booking checks only the session’s capacity. - List order —
findAllorders bysort_order ASC, name ASC. - Tier limit checked at create only — moving from a higher tier down does not auto-deactivate excess spaces. (TODO: verify behavior at tier-downgrade — there’s no explicit guard found.)
- Coords are an all-or-nothing — service does not reject saving only
latitudewithoutlongitude; both are nullable. GPS check-in is what enforces both must be present.
Spaces flag
Per-org flag spaces (FeatureFlags.SPACES), default off. Off, unset or unevaluable behaves exactly as before: no space checks, and override fields are ignored. The columns concurrency, sort_order and class_types.default_location_id, and the class-type default-space prefill, are not gated.
With the flag on, a session with a space is checked on single create (POST /sessions) and edit (PATCH /sessions/:id):
| # | Rule | Response |
|---|---|---|
| 1 | Space is exclusive and another live session in the same space overlaps [startsAt, endsAt) | 409 { code: 'space_conflict', message, locationId, conflictingSessionIds } |
| 2 | Session capacity > space capacity (both non-null) | 409 { code: 'space_capacity_exceeded', message, locationId, spaceCapacity, capacity } |
- Live means not
cancelled/archivedand not soft-deleted. Other sessions are ignored. - Adjacent is not overlap. A session ending at 08:00 and one starting at 08:00 can share an exclusive space.
sharedspaces allow any overlap. Rule 2 still applies.- Order. Rule 1 runs before rule 2. A client can get the conflict 409, retry with the conflict override, then get the capacity 409 and retry with both overrides.
- Override. Owner/admin may send
overrideSpaceConflict: { reason }and/oroverrideSpaceCapacity: { reason }(reason 1–500 chars, trimmed). The write proceeds and an audit rowclass_session.space_override(categoryoperations) records it. A coach sending an override that is needed gets 403. An override that is not needed is ignored. - Edit checks rule 1 only when
locationId,startsAtorendsAtis in the request, and rule 2 only whenlocationIdorcapacityis. - Bulk writers (template preview/apply, copy-week, Spotter
create_manyandreplicate_day) report and skip exclusive-space clashes; see scheduling-bookings behavior.
Golden paths
Owner: add a space
- Dashboard settings → Spaces (
/dashboard/settings/locations), or onboarding. - Click add. Form: name, address (autocomplete fills city/country/lat/lng), capacity, concurrency (
exclusivedefault, orshared). - POST
/organizations/:orgId/locations(optionalconcurrency,sortOrder≥ 0). Tier check runs first. - Row inserted; appears in the selector when creating sessions and in the class-type default space picker.
Owner: deactivate
- Click delete. DELETE
/organizations/:orgId/locations/:id. is_active=false. Removed from selector.
Edge cases & error states
| Scenario | Behavior |
|---|---|
| Create when tier limit reached | 403 ‘Space limit reached (N/M). Upgrade your plan to add more.‘ |
Create/update with concurrency not exclusive/shared, or sortOrder < 0 | 400 (DTO validation) |
| Coach/member tries to create/update/delete | 403 ‘Only owners and admins can …’ |
| Get/update/delete by id from another org | 404 ‘Location not found’ (WHERE includes organization_id=$orgId) |
| Member tries to GPS check-in to a session whose location has null coords | 400 ‘Session location coordinates are not configured’ (from ClassSessionsService.selfCheckin) |
Member tries GPS check-in but the session has location_id=NULL | 400 ‘Session location coordinates are not configured’ (same path; session.location is null) |
| Update with only some fields | Conditional spreads — only !== undefined fields apply |
Side effects
None outside the row mutation. No events, no notifications.
Permissions
| Action | owner | admin | coach | member |
|---|---|---|---|---|
| List | ✓ | ✓ | ✓ | ✓ |
| Get | ✓ | ✓ | ✓ | ✓ |
| Create | ✓ | ✓ | ✗ | ✗ |
| Update | ✓ | ✓ | ✗ | ✗ |
| Deactivate | ✓ | ✓ | ✗ | ✗ |
Active org membership required for all (requireMembership).