Prima Get an API key

Module kit / Calendar and booking

Meeting Type Configuration

calendar-booking/meeting-type-configuration

Kind backend
Domain calendar-booking
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, alembic-1.16, pydantic-2.12
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/alembic-migrations, _shared/test-layout
Frontends calendar-booking/ui/calendar-settings-panel
Reference implementation
  • alchemy_labs/backend/api/routers/calendar_router.py
  • alchemy_labs/backend/services/calendar_integration_service.py
  • alchemy_labs/backend/models/calendar_integration_model.py
  • alchemy_labs/backend/services/messaging_service.py
  • alchemy_labs/backend/migrations/versions/baseline_prod_schema_001.py
  • alchemy_labs/backend/migrations/versions/20260901_0001_booking_message_steps.py
  • alchemy_labs/backend/tests/v2/coverage/test_booking_g2_admin_platform_integration_meeting_type_blocked_time_booking_management.py
  • alchemy_labs/backend/tests/test_booking_message_steps.py
  • alchemy_labs/frontend/src/pages/CalendarSettings.tsx
  • alchemy_labs/frontend/src/pages/AdminCalendarSettings.tsx
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

Lets an operator (the platform's own shared calendar) or a workspace member (that workspace's calendar) define the bookable shape of one meeting type - its length, buffers, notice/lookahead window, weekly hours, location, questions and reminder/confirmation message overrides - and block off ranges of time nobody can book, all through ten CRUD endpoints on calendar_router.py that write directly to BookingMeetingType and CalendarBlockedTime (models/calendar_integration_model.py 161 to 253, 414 to 439). The invariant that makes it trustworthy is the three-way messages field: None (inherit the calendar's default_messages), [] (this meeting type explicitly sends nothing), or a list (its own steps) - round-tripped intact because both create and update pass data.messages straight through rather than data.messages or [] (calendar_router.py 562, 1180) and update reads the request with model_dump(exclude_unset=True) so "field omitted" and "field sent as null" stay distinguishable (1229 to 1231, 1238). Collapsing that distinction is the one mistake this recipe exists to prevent - see test_booking_message_steps.py's own docstring, lines 6 to 11.

When to use

Trigger phrases: "meeting type", "duration_minutes", "buffer_before/after", "min_notice_hours", "max_days_ahead", "availability_schedule", "booking_questions", "messages tri-state", "blocked time", "CalendarBlockedTime", "default_messages", "calendar settings", "admin calendar settings", "why did editing a meeting type 404". Read it before touching create_platform_meeting_type/create_workspace_meeting_type, update_platform_meeting_type/update_workspace_meeting_type, delete_platform_meeting_type/delete_workspace_meeting_type, create_blocked_time/delete_blocked_time, _workspace_meeting_type_or_404, _has_workspace_access, or CalendarIntegrationService.create_meeting_type/get_meeting_type_by_slug.

Not this recipe: the availability/slot computation that CONSUMES duration_minutes, the buffers, min_notice_hours, max_days_ahead, availability_schedule and CalendarBlockedTime rows to decide what is actually bookable (get_available_slots/get_availability_slots, calendar_integration_service.py 517 to 1044) - a neighbouring recipe, calendar-booking/availability-computation; OAuth connect/callback and iCal/CalDAV connection setup (calendar_router.py 249 to 465, 866 to 958) - calendar-booking/calendar-connection; the public booking flow, slot lock and cancel/confirm (create_public_booking, _lock_slot_or_409, cancel_public_booking, confirm_booking_by_token, 1391 to 1870) - calendar-booking/booking-confirmation-flow; the embed widget HTML/JS shell (get_embed_widget, 1943 to 2249) - calendar-booking/embed-widget-delivery; the message-step send/dispatch engine itself (messaging_service.dispatch, _dispatch_booking_messages, calendar_integration_service.py 1337 to 1390) - this recipe only owns where a meeting type's own steps come from (_resolve_message_steps, 1323 to 1335), not how they are sent; fix_names_and_create_english_duplicates (calendar_router.py 601 to 713), a one-time admin data-repair endpoint over the same table, read for context, not part of the CRUD contract.

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0, psycopg2-binary==2.9.10, alembic==1.16.5, starlette==0.48.0 (requirements.txt). Foundations assumed: fastapi-project-layout for the app, mount_router and DatabaseSession; auth-dependency-pattern for AdminUser/CurrentUser (api/dependencies.py 565, 568); workspace-scoping for the shape of the access check, though this router does not import the shared helper - see Security & tenancy for why; alembic-migrations for the schema; test-layout for the TestClient + transactional-db_session pattern both test files use.

No environment variable gates any endpoint in this recipe (contrast the auto-topup engine's PRIMA_LOW_BALANCE_ALERT_SECRET) - every route is reachable whenever /api/calendar is mounted, which is unconditional. router = APIRouter(tags=["Calendar Integration"]) (calendar_router.py 38) carries no router-level auth; per-route AdminUser/CurrentUser is what actually gates it, plus the router-level SurfaceGate every mounted router gets from mount_router(..., surfaces=LABS_SURFACES) (app_fastapi.py 845; api/surface_gate.py 265 to 274) - a request whose JWT audience does not name the LABS surface (core/surfaces.py 177) is refused before the handler runs at all, independent of is_admin.

Data model

booking_meeting_types, one row per configured meeting type, scoped by calendar_integration_id (models/calendar_integration_model.py 161 to 253):

booking_meeting_types (
  id                     uuid PRIMARY KEY,
  calendar_integration_id uuid NOT NULL REFERENCES calendar_integrations(id) ON DELETE CASCADE,
  slug                   varchar(100) NOT NULL,
  name                   varchar(255) NOT NULL,
  description            text,
  duration_minutes       integer NOT NULL DEFAULT 60,
  buffer_before_minutes  integer DEFAULT 0,
  buffer_after_minutes   integer DEFAULT 15,
  min_notice_hours       integer DEFAULT 24,       -- how soon before start a booking may be made
  max_days_ahead         integer DEFAULT 28,       -- lookahead window
  availability_schedule  jsonb DEFAULT '{"mon":["09:00-17:00"], ... "sat":[], "sun":[]}',
  location_type          varchar(50) DEFAULT 'google_meet',   -- google_meet|zoom|phone|in_person|custom
  custom_location        text, zoom_link varchar(500), phone_number varchar(50),
  color                  varchar(7) DEFAULT '#776ADD',
  cover_image_url        text,
  booking_questions      jsonb DEFAULT '[]',
  confirmation_message   text,
  send_confirmation_email boolean DEFAULT true,
  send_reminder_email    boolean DEFAULT true,
  reminder_hours_before  integer DEFAULT 24,
  messages               jsonb,                    -- TRI-STATE, no default: NULL=inherit, []=opt out, [...]=override
  embed_settings         jsonb DEFAULT '{"allow_embed":true, "allowed_domains":[], "theme":"light", ...}',
  is_active              boolean DEFAULT true,
  created_at timestamptz, updated_at timestamptz
);
UNIQUE (calendar_integration_id, slug)   -- uq_meeting_type_integration_slug, 251-253

calendar_integrations.workspace_id is what makes a row "platform-level" (NULL) vs "workspace-level" (a real workspace UUID); is_platform_level is a Python property reading exactly that (CalendarIntegration, 82 to 154). The same table also carries default_messages jsonb (108 to 111), the calendar-wide fallback a meeting type's own messages: NULL inherits.

calendar_blocked_times, manual no-booking windows, platform-only in this recipe's endpoints (models/calendar_integration_model.py 414 to 439):

calendar_blocked_times (
  id uuid PRIMARY KEY, calendar_integration_id uuid NOT NULL REFERENCES calendar_integrations(id) ON DELETE CASCADE,
  title varchar(255) DEFAULT 'Blocked', reason text,
  starts_at timestamptz NOT NULL, ends_at timestamptz NOT NULL,
  is_recurring boolean DEFAULT false, recurrence_rule text,   -- iCal RRULE; accepted by the column, not read anywhere in this recipe's endpoints
  created_by_id uuid REFERENCES users(id), created_at timestamptz
);

What is NOT stored, or not enforced, at this commit: no CHECK (ends_at > starts_at) on calendar_blocked_times, and BlockedTimeCreate (calendar_router.py 226 to 231) has no validator either - an inverted or zero-length block is accepted as-is; no overlap check, against either another blocked time or an existing booking, at creation time (that is the availability engine's job, out of scope here); no audit/version history of a meeting type's own edits, only updated_at; no soft-delete anywhere in this recipe - DELETE is a real db.delete() (see Gotchas for what that cascades into).

API surface

All under /api/calendar (app_fastapi.py 845).

RouteAuthTenancyCodesNotes
GET /admin/meeting-types (list_platform_meeting_types, 498-515)AdminUserplatform (workspace_id IS NULL)200; 404 no platform integrationSide effect: calls _create_default_meeting_types, idempotently seeding 3 fixed types on every call (calendar_integration_service.py 286-367; proven idempotent by test_list_meeting_types_creates_defaults_idempotently)
POST /admin/meeting-types (create_platform_meeting_type, 518-572)AdminUserplatform200; 404 no integration; 400 duplicate slug; 422 validation
PUT /admin/meeting-types/{id} (update_platform_meeting_type, 575-598)AdminUserplatform, no ownership check beyond row existence200; 404 unknown idAny admin can edit any platform meeting type by id; there is only one platform integration so this is not cross-tenant, but it is looked up with no calendar_integration_id filter at all
DELETE /admin/meeting-types/{id} (delete_platform_meeting_type, 716-733)AdminUserplatform200 {"success": true, ...}; 404 unknown idHard delete, cascades to bookings - see Gotchas
POST /admin/blocked-times (create_blocked_time, 769-792)AdminUserplatform only, no workspace equivalent exists200; 404 no integrationStamps created_by_id = admin.id
DELETE /admin/blocked-times/{id} (delete_blocked_time, 842-859)AdminUserplatform200 {"success": true}; 404 unknown idNo GET /admin/blocked-times exists - nothing in this recipe lists existing blocks; see Frontend
GET /workspace/meeting-types?workspace_id= (list_workspace_meeting_types, 1087-1104)CurrentUser + _has_workspace_access (or admin)workspace, via get_primary_workspace_integration200 ([] if no integration); 403 no access
POST /workspace/meeting-types?workspace_id= (create_workspace_meeting_type, 1134-1190)sameworkspace200; 403; 404 no calendar connected; 400 dup slugIdentical body/defaults shape to the platform create
PUT /workspace/meeting-types/{id}?workspace_id= (update_workspace_meeting_type, 1214-1243)sameworkspace, enforced by _workspace_meeting_type_or_404 (1193-1211) filtering on id AND calendar_integration_id200; 403; 404 unknown-or-not-yoursRouter's own docstring (1222-1231): this route did not exist until 2026-09-01 though the frontend had been calling it all along, so editing 404'd silently and only create ever worked; the same was true of the DELETE below
DELETE /workspace/meeting-types/{id}?workspace_id= (delete_workspace_meeting_type, 1246-1261)sameworkspace, same _workspace_meeting_type_or_404200; 403; 404Hard delete, same cascade as the platform route
GET /workspace/integration/default-messages?workspace_id= (1302-1316)sameworkspace200 {"messages": ...}; 403; 404
PUT /workspace/integration/default-messages?workspace_id= (1319-1344)sameworkspace200; 403; 404Only affects bookings made after the call (docstring 1327-1331); no platform-level equivalent exists even though CalendarIntegration.default_messages is a column on every integration, platform included

Application logic

Create (create_platform_meeting_type / create_workspace_meeting_type, near-identical bodies), in order:

  1. Resolve the integration - get_platform_integration (413-419, filters workspace_id IS NULL, provider='google', is_active) or get_primary_workspace_integration (438-457, prefers Google, falls back to the most recently connected active integration of any provider). No integration → 404.
  2. get_meeting_type_by_slug(db, integration.id, data.slug) (500-511) - scoped to the integration and is_active == True. A match → 400 "Meeting type with slug '{slug}' already exists".
  3. CalendarIntegrationService.create_meeting_type (478-498) constructs BookingMeetingType(**kwargs), db.add, db.commit, db.refresh. The router fills omitted availability_schedule/embed_settings with the same Mon-Fri-09:00-17:00 / allow_embed:true defaults the model itself declares (545-553, 563-569 vs model 188-196, 229-237) but passes messages=data.messages through unmodified - never or [] - so an omitted field stays None and inherits (562, 1180).

Update (update_platform_meeting_type / update_workspace_meeting_type):

  1. Fetch - platform: db.query(BookingMeetingType).filter(.id == meeting_type_id).first(), no tenancy filter (583-585); workspace: _workspace_meeting_type_or_404, filtered by both id and the caller's own calendar_integration_id so an id from another workspace's calendar 404s (1193-1211). Not found → 404.
  2. update_data = data.model_dump(exclude_unset=True); for field, value in update_data.items(): setattr(meeting_type, field, value) (591-593, 1238-1239) - every present key is applied with no per-field business validation beyond what MeetingTypeUpdate's Pydantic types already enforce, and MeetingTypeUpdate declares no Field(ge=..., le=...) bounds at all (90-110), unlike MeetingTypeCreate (68-88: duration_minutes 15-480, buffers 0-60, min_notice_hours 0-168, max_days_ahead 1-90) - see Gotchas.
  3. db.commit(), db.refresh(), return.

Delete (delete_platform_meeting_type / delete_workspace_meeting_type): fetch (same scoping as update), db.delete(meeting_type), db.commit(), return {"success": True, "message": "Meeting type deleted"} (716-733, 1246-1261). No confirmation step, no check for existing bookings before deleting.

Blocked times: create_blocked_time resolves the platform integration (404 if none), builds CalendarBlockedTime(calendar_integration_id, title, reason, starts_at, ends_at, created_by_id=admin.id), adds, commits (780-792). delete_blocked_time looks the row up by id with no integration filter (there is only one platform integration, so this is not a tenancy gap the way it would be at workspace scope), 404 if missing, else deletes and commits (842-859).

Invariants

  • A meeting type's slug is unique per calendar integration at the database level (uq_meeting_type_integration_slug, model 251-253) whether or not the create-time application check (is_active-scoped get_meeting_type_by_slug) caught the collision first - see Gotchas for the gap this opens.
  • messages survives exactly three states - omitted (leave alone), null (inherit CalendarIntegration.default_messages), a list (override) - because create and update both avoid x or [] (562, 1180) and update reads with exclude_unset=True (1238); proven by test_none_inherits_the_calendar_default, test_empty_list_opts_out_and_does_not_inherit, test_own_steps_win_over_the_default (test_booking_message_steps.py 44-58).
  • update_workspace_meeting_type/update_platform_meeting_type never touch a field the caller did not send, and a caller who sends a field explicitly as null gets that field set to None, not skipped - test_update_meeting_type_partial_persists asserts the untouched slug keeps its original value while name/duration_minutes change (test_g2.py 341-357).
  • A workspace member (or admin, who bypasses the check) may read, create, update or delete meeting types only for a workspace _has_workspace_access confirms them a member or owner of (959-979); the access check runs before the resource is even looked up on every workspace route (e.g. 1233-1236).
  • Deleting a meeting type deletes its bookings too, unconditionally - see Gotchas; this recipe's own tests never assert the opposite, so nothing here claims booking history survives a delete.

Frontend

calendar-booking/ui/calendar-settings-panel   the CRUD surface this recipe backs: create/edit/delete a
                                               meeting type's duration, buffers, notice/lookahead,
                                               weekly schedule, location, questions and message
                                               overrides, plus platform blocked-time management

Real Labs pages this module would be modeled on, neither reference-implemented yet in this repo: frontend/src/pages/CalendarSettings.tsx (workspace view - calls GET/POST/PUT/DELETE /api/calendar/workspace/meeting-types at lines 191, 354, 361, 384) and frontend/src/pages/AdminCalendarSettings.tsx (platform/admin view, same shape against /admin/meeting-types).

Minimum admin/debug surface at this commit: GET /admin/meeting-types (all platform meeting types) and GET /workspace/meeting-types?workspace_id= (one workspace's) are the only listing endpoints that exist - there is no GET /admin/blocked-times, so nothing in the API can list existing platform blocks; a human reads them from the database directly (SELECT * FROM calendar_blocked_times WHERE calendar_integration_id = :id ORDER BY starts_at). There is also no workspace-level blocked-time endpoint of any kind (create, list or delete) - CalendarBlockedTime has no workspace_id column and no route in this file ever creates one off a workspace integration; only the platform calendar can carry manual blocks through this API.

Security & tenancy

Auth: AdminUser (get_current_admin_user, api/dependencies.py 364-375, requires is_admin on top of get_current_active_user) gates every /admin/* route in this recipe; CurrentUser (get_current_user, 187-266, JWT bearer/cookie then X-API-Key fallback) plus an explicit inline _has_workspace_access check gates every /workspace/* route. Both sit behind the router-level SurfaceGate from mount_router(..., surfaces=LABS_SURFACES) (see Stack assumptions).

Tenancy: platform rows have no workspace concept by construction (workspace_id IS NULL, is_platform_level, model 93, 151-154). Workspace routes take workspace_id as an explicit Query parameter, not derived from the session, and every one of the eight workspace handlers open-codes if not user.is_admin and not _has_workspace_access(db, user.id, workspace_id): raise HTTPException(403, ...) rather than sharing one dependency (1094, 1116, 1142, 1233, 1254, 1276, 1309, 1333). _has_workspace_access itself is defined inline in calendar_router.py (959-979) rather than imported from the shared workspace-scoping module - its own docstring says why: api.dependencies re-exports via import *, which drops underscore-prefixed helpers, so this file keeps its own copy (962-964). It runs one raw SQL query per call (LEFT JOIN workspace_users ... WHERE w.id = :wid AND (w.owner_id = :uid OR wu.id IS NOT NULL)), live, uncached, per request. _workspace_meeting_type_or_404 (1193-1211) is what actually stops one workspace editing another's meeting type by id: it re-derives the integration from workspace_id and filters the row by both id and calendar_integration_id, so a foreign id 404s rather than 403ing or succeeding - its own docstring makes the point explicit (1194-1198).

Fail direction: the access check runs before the resource lookup on every workspace route (403 before 404 is even possible), and every admin route fails closed on a non-admin (403). Attacker-controlled inputs: workspace_id itself is caller-supplied and trusted only as far as _has_workspace_access's live join; duration_minutes/buffers/min_notice_hours/max_days_ahead are bounded on create by Field(ge=..., le=...) but not on update (Gotchas); booking_questions, embed_settings and messages are arbitrary caller-supplied JSON (Optional[List[Dict]]/Optional[Dict]) with no shape validation in this recipe - that validation, if any, lives wherever they are rendered or sent, not here. No rate limit is attached to any of the ten endpoints (no RateLimitDependency in any of their signatures at this commit). The only PII this recipe writes is calendar_blocked_times.created_by_id, the acting admin's own id (786).

Tests

Two files. tests/v2/coverage/test_booking_g2_admin_platform_integration_meeting_type_blocked_time_booking_management.py is an HTTP-route suite (admin_client/authenticated_client/client fixtures over a real TestClient) covering every platform-level route in this recipe plus platform integration get/list/disconnect (out of this recipe's scope, same file). tests/test_booking_message_steps.py is a hermetic, no-database suite covering the tri-state resolver directly.

Measured 2026-09-12, alchemy_labs/backend, .venv (Python 3.13.7), against the deap-postgres-test container already listening on localhost:5434 (TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db, the suite's own default per tests/conftest.py 44-47):

.venv/bin/python -m pytest tests/v2/coverage/test_booking_g2_admin_platform_integration_meeting_type_blocked_time_booking_management.py -q --no-cov -p no:cacheprovider

printed 43 passed, 1 error on a clean first invocation (44 def test_ total, 0 parametrize). Two further invocations against the same long-lived container, with no schema reset between them, printed 24 errors (of 48 collected across a combined run) and then 13 passed, 31 errors - all as sqlalchemy.exc.ProgrammingError: relation "users" does not exist inside db.add(User(...)) fixture setup, never inside this recipe's own assertions. tests/v2/coverage/conftest.py's own docstring already flags this (21-25): the session-scoped test_engine fixture's Base.metadata.drop_all/create_all (tests/conftest.py 99-103) is supposed to give each pytest invocation a clean schema, and does on a genuinely fresh container, but "does not always round-trip cleanly between separate pytest invocations" against a Postgres the previous run's teardown already touched. What exactly degrades between invocations is not determined here - the suite's own docstring names the symptom, not the cause, and this recipe did not chase it further. Practical consequence for a rebuild: run this file's suite first, alone, right after the test Postgres starts, or reset the public schema before each invocation; do not treat a second run's failures as evidence against the code without ruling this out first.

.venv/bin/python -m pytest tests/test_booking_message_steps.py -q --no-cov -p no:cacheprovider

printed 28 passed in 0.67s, cleanly, every run (24 def test_, 2 parametrize decorators expanding to 28) - hermetic by construction (unittest.mock.MagicMock for the one test needing a db, no engine, no fixture).

Coverage gap, measured by searching the wider suite rather than assumed: tests/v2/coverage/test_booking_g3_workspace_tenant_integration_endpoints_ical_caldav_connect_access_control.py (not one of this recipe's two named test files, read only to check for overlap) covers POST/GET /workspace/meeting-types (its own lines 273-356) but no test anywhere in the repo exercises PUT or DELETE /workspace/meeting-types/{id} - grep -rn "update_workspace_meeting_type\|delete_workspace_meeting_type" tests/ returns nothing. Given the router's own comment that this exact route "did not exist until 2026-09-01... so editing a meeting type from the workspace calendar UI 404'd, silently" (1224-1227), the absence of a regression test for it is the kind of gap that let that bug ship unnoticed the first time.

Deploy & ops

No dedicated config keys - nothing in this recipe reads core/config.py. Migrations: the schema in Data model is created by baseline_prod_schema_001 (revision = 'baseline_prod_schema_001', down_revision = None, migrations/versions/baseline_prod_schema_001.py 545-547 booking_meeting_types, 666-668 calendar_blocked_times, 729-731 calendar_integrations, 2274-2280 the slug unique constraint) - a single squashed snapshot, not the original incremental history. The tri-state messages/default_messages columns were added later by booking_msg_steps_001 (down_revision = 'sparks_team_001', migrations/versions/20260901_0001_booking_message_steps.py 34-39), nullable with no default on both tables, which is what makes the tri-state possible at all - a server_default here would have made every pre-existing row's messages collapse to a non-NULL value on the next write.

No scheduler job in this recipe - nothing here runs on a timer; availability computation and message dispatch (which do) are neighbouring recipes. Rollback: both tables are additive-only from this recipe's own endpoints (no destructive migration lives here); a code rollback needs no data unwind, though any meeting type or blocked time created or edited after the rollback point is simply gone from the running code's perspective until roll-forward.

Argus-style check: GET /api/calendar/admin/meeting-types with a valid admin session returns 200 with at least the 3 seeded defaults (essenz-workshop, strategie-praesentation, final-handover) once a platform integration exists; a 404 here means no platform calendar is connected, not that this recipe is broken.

Gotchas

  • 2026-09-12 (measured, calendar_router.py 90-110 vs 68-88): MeetingTypeUpdate carries none of MeetingTypeCreate's Field(ge=..., le=...) bounds on duration_minutes, buffer_before_minutes, buffer_after_minutes, min_notice_hours or max_days_ahead. POST rejects duration_minutes: 5 with 422 (test_create_meeting_type_validation_error, test_g2.py 312-318); the equivalent PUT has no test and, reading the code, would accept it - or 0, or a negative buffer. Not chased further than the source read; treat as unverified-by-test rather than confirmed-broken.
  • 2026-09-12 (measured, model.py 244-253 vs 269): BookingMeetingType.bookings is declared cascade="all, delete-orphan" (248), so db.delete(meeting_type) in both delete endpoints (733, 1259) has SQLAlchemy's ORM delete every associated CalendarBooking row in Python before issuing the parent DELETE. But CalendarBooking.meeting_type_id's own FK is ondelete="SET NULL" (269) - a DB-level declaration that a deleted meeting type should orphan its bookings, not erase them. Because the delete goes through the ORM (not a raw DELETE FROM booking_meeting_types), the Python-side cascade runs first and the DB's SET NULL intent never fires: deleting a meeting type destroys its booking history, contradicting what the FK itself says should happen. Neither test file exercises a meeting type with bookings attached at delete time, so this is read from the two declarations disagreeing, not from an observed failure.
  • 2026-09-12 (measured, calendar_integration_service.py 507-511 vs model 251-253): get_meeting_type_by_slug's duplicate-slug pre-check filters is_active == True. MeetingTypeUpdate.is_active (106) lets a caller deactivate a meeting type via PUT. A meeting type deactivated this way then has a slug the pre-check can no longer see - a subsequent POST with the same slug passes the 400 check and reaches db.add, where the table's real UniqueConstraint('calendar_integration_id', 'slug') (251-253) throws an uncaught IntegrityError; no try/except IntegrityError wraps either create endpoint (IntegrityError is imported at line 21 but used only once in the whole file, at the unrelated booking slot-lock, line 1571). Reachable through legitimate API use (deactivate, then recreate with the same slug); not exercised by either test file.
  • 2026-09-12 (measured, source contains real client-facing meeting-type names, e.g. the one-time fix_names_and_create_english_duplicates handler, 601-713): the actual German/English meeting-type names and a named client mentioned in that handler's restore_map are Prima's own client data, not fixtures; this recipe describes the shapes and behavior only and does not reproduce those names.

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/api/routers/calendar_router.py (2485 lines, whole file in scope for mount context, this recipe's slice): schemas (45-242); admin meeting-type/blocked-time routes (498-733, 769-792, 842-859); _has_workspace_access/_assert_calendar_connect_access (959-998); workspace meeting-type/default-messages routes (1087-1344); _workspace_meeting_type_or_404 (1193-1211).
  • alchemy_labs/backend/services/calendar_integration_service.py (1716 lines): _create_default_meeting_types (286-367); get_platform_integration/get_workspace_integration/get_primary_workspace_integration (413-457); create_meeting_type/get_meeting_type_by_slug (478-511); _resolve_message_steps (1323-1335).
  • alchemy_labs/backend/models/calendar_integration_model.py (439 lines, whole file in scope): CalendarIntegration (82-154); BookingMeetingType (161-253); CalendarBlockedTime (414-439).
  • alchemy_labs/backend/services/messaging_service.py: resolve_steps (184-202), the tri-state rule itself, cited for context - this recipe calls it, does not own it.
  • alchemy_labs/backend/app_fastapi.py: the mount (845); LABS_SURFACES import (139).
  • alchemy_labs/backend/api/surface_gate.py: mount_router/SurfaceGate (63-91, 265-274).
  • alchemy_labs/backend/api/dependencies.py: get_current_user (187-266); get_current_admin_user (364-375); CurrentUser/AdminUser/DatabaseSession (565-572).
  • alchemy_labs/backend/migrations/versions/baseline_prod_schema_001.py: table definitions (545-547, 666-668, 729-731) and the slug unique constraint (2274-2280).
  • alchemy_labs/backend/migrations/versions/20260901_0001_booking_message_steps.py: the messages/default_messages columns (26-45).
  • alchemy_labs/backend/tests/v2/coverage/test_booking_g2_admin_platform_integration_meeting_type_blocked_time_booking_management.py (650 lines).
  • alchemy_labs/backend/tests/test_booking_message_steps.py (242 lines).
  • alchemy_labs/frontend/src/pages/CalendarSettings.tsx (1369 lines) and AdminCalendarSettings.tsx (969 lines), read only for their call sites (191, 354, 361, 384 in the former), not otherwise measured here.

Deviations of the reference from an ideal implementation, all dated 2026-09-12: MeetingTypeUpdate lacks MeetingTypeCreate's numeric bounds; a deactivated meeting type's slug can collide uncaught at create time; hard-delete cascades to booking history against the FK's own SET NULL intent; no workspace-level blocked-time endpoint exists at all; no GET /admin/blocked-times listing endpoint exists; no platform-level default-messages endpoint exists even though the column is shared by both levels; _has_workspace_access is duplicated in this file rather than imported from a shared module, by the file's own admission.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc, .venv active, deap-postgres-test listening on localhost:5434.

  1. .venv/bin/python -m pytest tests/test_booking_message_steps.py -q --no-cov -p no:cacheprovider prints 28 passed (hermetic, reproducible on any invocation).
  2. On a freshly started/reset test Postgres, TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db .venv/bin/python -m pytest tests/v2/coverage/test_booking_g2_admin_platform_integration_meeting_type_blocked_time_booking_management.py -q --no-cov -p no:cacheprovider alone (first invocation after reset) prints 43 passed, 1 error; a run against a container already exercised by a prior invocation is not expected to reproduce this cleanly (Tests).
  3. grep -c "^def test_" tests/v2/coverage/test_booking_g2_admin_platform_integration_meeting_type_blocked_time_booking_management.py prints 44; grep -c "^def test_" tests/test_booking_message_steps.py prints 24, grep -c "@pytest.mark.parametrize" tests/test_booking_message_steps.py prints 2 (24 defs + 4 expansions from two 3-way parametrizations = 28 collected, matching check 1).
  4. sed -n '90,110p' api/routers/calendar_router.py | grep -c 'ge=' prints 0; sed -n '68,88p' api/routers/calendar_router.py | grep -c 'ge=' prints 5 - confirms the bounds gap in Gotchas.
  5. sed -n '269p' models/calendar_integration_model.py shows ondelete="SET NULL" on meeting_type_id; sed -n '248p' models/calendar_integration_model.py shows cascade="all, delete-orphan" on the bookings relationship of the SAME class (BookingMeetingType) - confirms the two declarations disagree as described in Gotchas.
  6. grep -c "IntegrityError" api/routers/calendar_router.py prints 3 (the import, one comment, one actual except, none of them around meeting-type creation) - confirms the uncaught-duplicate-slug path.
  7. grep -rn "GET.*admin/blocked-times\|@router.get(\"/admin/blocked-times\"" api/routers/calendar_router.py prints nothing - confirms no listing endpoint exists.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.