---
title: "Host Activity Sites"
slug: host-activity-sites
section: Portals
group: Host Portal
status: stable
description: "Frozen contract for the host_activity_sites data model, the internal-staff admin CRUD surface that manages it, the program-model gate that restricts which hosts may own activity sites, the audit vocabulary, and the admin host-detail UI p…"
last-updated: 2026-10-07
---

# Host Activity Sites

Frozen contract for the host_activity_sites data model, the internal-staff admin CRUD surface that manages it, the program-model gate that restricts which hosts may own activity sites, the audit vocabulary, and the admin host-detail UI placement. A host organization can operate multiple distinct physical activity sites; this contract gives Globus staff a first-class way to record them instead of overloading the single host address.

> **Who reads this**
>
> Globus platform engineers and the internal admin / host-management team. This page is the authoritative contract for the host_activity_sites table and the /api/admin/partners/organizations/:orgId/activity-sites endpoints. It is a sibling of the Host Camp Overview contract and reuses the same structured-address and audit conventions.

> **SHIPPED — and the as-built contract differs from the proposal below (verified 2026-07-04)**
>
> The feature is live in Globus: routes in server/routes/partners/activitySites.ts, schema hostActivitySites in shared/schema.ts, migrations 20260716000000_host_activity_sites.sql + 20260717000000_..._state_country_fk.sql, UI in activity-sites-tab.tsx on partner-detail (Tasks #668/#694). Where this page's original proposal and the shipped code disagree, the shipped code wins: &bull; Routes: /api/admin/partners/organizations/:orgId/activity-sites (GET list, POST create 201, PATCH /:siteId, DELETE /:siteId 204) — not /api/admin/hosts/:hostId/.... &bull; RBAC: isAuthenticatedOrApiKey("partners.read") on all, plus partners.view for GET and partners.manage for writes — no new host.activity_sites.* keys. &bull; Program-model gate: assertActivitySitesApplicable() runs on POST, PATCH and DELETE; it resolves the org's active program associations through resolveProgramModelType() (applicable if ANY program is camp or work_travel) and rejects with 409 ACTIVITY_SITES_NOT_APPLICABLE (not 422). &bull; Columns: id varchar PK gen_random_uuid() (not serial), partner_org_id varchar NOT NULL REFERENCES partner_organizations ON DELETE CASCADE (cascade — not leaf-row-only), site_name NOT NULL, address fields, on-site contact as contact_name / contact_phone / contact_email (not onSiteContact*), capacity int (informational), created_at / updated_at DEFAULT now() (updatedAt is never null). Index: idx_host_activity_sites_org. &bull; Country/state correctness is enforced by composite FKs to system_lookups (groups reference_country and us_state, the state FK applying only when country='US', via generated STORED discriminator columns) — not by Zod length(2) / refine rules. Validation is insertHostActivitySiteSchema (drizzle createInsertSchema; siteName trimmed min 1, capacity coerced int ≥ 0 nullish, contactEmail email-or-empty), with normalizeAddressFields() before persist. &bull; Audit: host.activity_site.created / .updated / .deleted via storage.createAuditLog (best-effort), entityType host_activity_site, newValue carrying partnerOrgId + siteId + changedFields. PATCH/DELETE on a site not owned by :orgId → 404. Tests: server/__tests__/activitySites.test.ts, server/routes/hostPortal/activitySites.test.ts. The sections below are retained as the original design contract; read them through this correction.

## Purpose & Scope

A host organization (a camp or a work & travel employer) frequently runs participants across more than one physical location — a waterfront and a base camp, or a hotel plus an off-site conference venue. The single inline address on partner_organizations and the seasonal addresses on partner_addresses cannot represent this one-host-to-many-sites relationship. Host Activity Sites adds a dedicated child table so Globus staff can record each operating location with its own name, structured address, on-site contact, and capacity.

Activity sites are managed by internal Globus staff through the admin endpoints and also by host users through the Host Portal (/api/partner/host/activity-sites). The CRUD surface is therefore an admin endpoint family, gated by staff RBAC permissions and surfaced inside the admin host-detail page — not the host-facing portal.

## Globus System of Record

Globus is the system of record for activity sites. The host_activity_sites rows live in the Globus database alongside partner_organizations. No external system (HubSpot, NetSuite, SEVIS) owns this data; downstream consumers read it from the Globus admin API. The site list is independent of the legacy single-address columns and of the seasonal-address projection — they are separate surfaces over the same host org.

## Data Model

### host_activity_sites Columns

| Column | Type | Description |
| --- | --- | --- |
| field | type | desc |

_(row above is a template, repeated for each data row)_

### Indexing & Relationships

## Program-Model Gate

Only host orgs on the camp and work_travel program models can own activity sites. The gate resolves the org's program model type from its active program associations (resolveProgramModelType); applicable if any is camp or work_travel.

> **Gate is enforced at write time, on the server**
>
> POST resolves the org's program model type from its active program associations and returns 409 ACTIVITY_SITES_NOT_APPLICABLE when none of them is camp or work_travel — e.g. a hospitality host. No row is written. The allow-list lives in one server-side constant so a new eligible program model is a one-line change. The UI mirrors the gate (hides or disables the panel) but the server is the enforcement boundary — a direct API call against an ineligible host must still be rejected.

## Admin CRUD Endpoints

| Method | Path | Description | Permission |
| --- | --- | --- | --- |
| method | path | desc | perm |

_(row above is a template, repeated for each data row)_

## Auth & RBAC

These are internal admin endpoints. They authenticate the Globus staff principal (admin/staff session) and gate on two new permission keys. There is no host-portal Bearer path on this surface — host users do not manage their own activity sites.

| Permission Key | Description | Staff Admin | Ops Manager | Read-Only |
| --- | --- | --- | --- | --- |
| key | desc | | | |

_(row above is a template, repeated for each data row)_

## Validation (Zod)

_Status: as built, request bodies are validated with `insertHostActivitySiteSchema` (a drizzle `createInsertSchema`, `shared/schema.ts`), the on-site contact fields are `contactName`, `contactPhone` and `contactEmail`, and country/state correctness is enforced by composite foreign keys to `system_lookups`. The schema sketch below (`activitySiteCreateSchema`, `onSiteContact*`, `length(2)` refine) is the original design; see the SHIPPED note at the top._

Request bodies are validated at the route boundary before they reach the storage layer. Two schemas are derived in shared/schema.ts:

// activitySiteCreateSchema (POST)

siteName: z.string().trim().min(1).max(255), // required

street: z.string().max(500).optional(),

city: z.string().max(255).optional(),

state: z.string().max(100).optional(),

postalCode: z.string().max(50).optional(),

country: z.string().length(2).optional(), // ISO-3166 alpha-2

onSiteContactName: z.string().max(255).optional(),

onSiteContactPhone: z.string().max(50).optional(),

onSiteContactEmail: z.string().email().max(255).optional(),

capacity: z.number().int().min(0).optional(),

.strict()

.refine(s !s.onSiteContactName ||

!!(s.onSiteContactPhone || s.onSiteContactEmail),

A contact name requires a phone or email)

// activitySiteUpdateSchema (PATCH) = create schema, all fields

// optional, .strict(), with a .refine() that at least one field is present.

> **Contact rule**
>
> When onSiteContactName is supplied, at least one of onSiteContactPhone or onSiteContactEmail must also be present so a named contact is always reachable. A site with no contact at all is permitted.

## ActivitySite Resource Shape

// GET /api/admin/partners/organizations/:orgId/activity-sites — array element

"id": 41,

"partnerOrgId": 7,

"siteName": "North Lake Waterfront",

"street": "120 Shoreline Rd",

"city": "Bridgton",

"state": "ME",

"postalCode": "04009",

"country": "US",

"onSiteContactName": "Dana Reyes",

"onSiteContactPhone": "+1-207-555-0142",

"onSiteContactEmail": "dana@pinewoodcamp.example",

"capacity": 80,

"createdAt": "2026-05-02T15:11:00Z",

"updatedAt": null

## Error Responses

| Code | Reason |
| --- | --- |
| code | reason |

_(row above is a template, repeated for each data row)_

## Audit Actions

Every mutation writes one audit row through storage.createAuditLog, best-effort (a failed audit write is logged and swallowed), after the mutation succeeds. As built, the actions are `host.activity_site.*` (not `host_activity_site.*`), written by both the staff admin routes and the Host Portal routes.

| Action | Trigger | previousValue / newValue |
| --- | --- | --- |
| host.activity_site.created | POST success | previousValue = null; newValue = the submitted fields plus partnerOrgId, siteId, changedFields and source. |
| host.activity_site.updated | PATCH success | previousValue = the prior site row; newValue = the submitted fields plus partnerOrgId, siteId, changedFields and source. |
| host.activity_site.deleted | DELETE success | previousValue = the deleted site row; newValue = partnerOrgId, siteId, changedFields (empty) and source. |

entityType = `host_activity_site`, entityId = the site id; source is `staff` for the admin routes or `host_portal` for the Host Portal routes (with actingHostUserEmail when the X-Host-User-Email header is present). Entries are queryable from the Audit Console .

## Admin Host-Detail UI Placement

The admin host-detail page gains an Activity Sites panel, placed alongside the existing host profile / address sections. It lists each site as a card (name, address, on-site contact, capacity) with Add / Edit / Delete affordances.

## Acceptance Criteria

## Implementation & Cross-Repo Routing

> **Platform work is filed against the owning app repo**
>
> Per the cross-repo routing rule, the schema migration, Drizzle table + Zod schemas, storage methods, admin routes, audit wiring, and the admin host-detail UI panel are not built in this docs-site repo. They are tracked as an Asana implementation ticket in the P4.B Host Management Updates project, authored to the house Asana ticket standard with a Gherkin acceptance-criteria section and an attached QA test plan. This page is the frozen contract that ticket implements.

## Cross-References

- Host Camp Overview — Contract Spec — sibling host contract; structured-address and seasonal-address conventions reused here.
- Host Portal Governance — host authority model and the program-model field.
- Audit Console — where host_activity_site.* audit entries surface.
