← All stories

BRANCH · ef-008-create-or-copy-event

Create or copy event

EF-008 Persona: Organizer Stage: Planning Roots in: Admin ShellEvent Setup (planned) EF reference: EventFarm doc

The organizer either has a brand-new event in mind, or they're cloning a past event because last quarter's annual gala had the access types and email designs already configured. Whichever path they pick, success means landing in the Event Setup hub for the new event with sane defaults and an obvious next action. The interesting failure modes happen at the edges: the source event was deleted between picker and submit, the user's quota is full, two organizers race to copy the same source.

Happy path

Path A — Create from scratch

The most common first-event flow. The organizer has a name, a date, a location, and an idea of capacity. They want to get past the form and into the configurable surface as quickly as possible.

  1. From the home dashboard, click Create event.

    The button is a primary CTA in the header chrome (always-visible) and also appears as the first card in the home grid for users with zero events. Clicking opens a modal — not a full-page navigation — so the user keeps their place if they cancel.

  2. Choose Start from scratch in the modal's two-card layout (the other card is "Copy from past event").

    The two-card chooser is the modal's only content; no clutter. Each card has an icon, a one-line description, and a clickable hit area covering the full card.

  3. Fill in the minimum fields: name, start date/time, end date/time, timezone, location.

    Capacity is optional at this step (defaults to "unlimited" with a warning). Timezone defaults to the user's browser timezone but is editable. Date pickers respect the timezone — the inputs show local time in that zone, not UTC. As the user types the name, a slug preview appears below it; the slug is editable but auto-generated.

  4. Click Create.

    The button enters a loading state with the form fields locked (so no double-submit). The request hits POST /v1/admin/events and on success the modal dismisses and the page navigates to /admin/events/<event-id>/setup.

  5. Land in the Event Setup hub with sane defaults.

    The hub shows the event's name and date prominently. A checklist on the right ("Add an access type · Configure registration email · Invite guests · Publish your event page") guides the next action. The default access type is "General Admission" with capacity matching the event's overall capacity, so the user can ship a one-tier event with zero further configuration.

Path B — Copy from past event

Heavier than Path A. The user is choosing what to bring forward and what to start fresh.

  1. Click Copy from past event in the chooser modal.

    The modal expands to a two-pane layout: left is a searchable list of the user's past events (sorted by most-recent end date), right is a copy-options panel that's empty until a source is picked.

  2. Pick a source event.

    The right pane fills with checkboxes for what to copy: Access types (default on), Email designs (default on), Registration questions (default on), Guest list (default off — opt-in because it brings PII), Schedule / agenda (default on if present), Brand & colors (default on). Each row has a count next to it ("12 access types", "4 email designs") so the user knows what they're bringing.

  3. Adjust copy options, fill in name + new dates.

    The new event's name defaults to "<Source name> (Copy)" but the field is highlighted so the user is nudged to rename. Start/end dates do NOT default — the user must enter them, because dragging old dates forward silently is a foot-gun.

  4. Click Create copy. The request hits POST /v1/admin/events with a source_event_id field and the per-section copy flags.

    On the backend this is a multi-table copy in a single D1 batch transaction (per the existing event-clone code path at workers/events/src/routes/events.ts:2667). On success the page navigates to the new event's setup hub. A toast confirms the count of copied items: "Created Annual Gala 2026 with 12 access types and 4 email designs."

Failure modes

The interesting ones — most of these don't show up in unit tests but absolutely show up in user trust if mishandled. Component-tier failure modes (focus trap escapes, esc-form-submit leaks, backdrop close when disabled, scroll leaks, missing aria-modal, layered z-index leaks, initial focus on hidden elements) are owned by the ui-modal component story and inherited automatically — not re-listed here. EF-008's failure modes below are the EF-008-specific ones: validation, copy-source-deleted, partial-copy, name-collision races.

Validation failure: name missing, end before start, capacity negative

Trigger: user submits the form with one or more fields invalid.

Errors render inline next to the offending field (not as a top-of-form summary, not as a toast). The error text says what's wrong AND how to fix: not "Invalid date" but "End time must be after start time." Multiple errors render simultaneously; the user doesn't have to fix one and resubmit to discover the next. The submit button stays enabled (so the user can re-attempt after fixing) but loses its primary-CTA styling until validation passes.

Recovery: Fix-as-you-type. Each error clears as the user resolves the field.

Validation failure: end and start more than 1 year apart

Trigger: user enters a date range > 365 days, which is almost always a fat-finger.

The form shows a soft warning (not a hard error): "This event is more than a year long. Did you mean <suggested shorter range>?" The user can accept the suggestion with one click, or proceed with the long range. This isn't a blocking validation — multi-year exhibitions are a real use case — but it surfaces the most likely typo.

Recovery: Click the suggested range to auto-fill, or click Create to proceed.

Network failure on submit

Trigger: user clicks Create, the request fails (offline, DNS, server connection dropped).

The button returns to its non-loading state. An inline error message appears above the form — not a toast (toasts disappear and the user might miss them). The error says "Couldn't create the event. Check your connection and try again." Form input is preserved exactly. If the user retries within 30s of the original click, the request includes an idempotency key derived from the form content + timestamp, so a server that did receive the original (but the response was lost) doesn't double-create.

Recovery: Click Create again. Idempotency key prevents duplicate.

Server returns 500

Trigger: server crashed mid-create, returned a 500.

Same UX as network failure but the error message includes a request ID the user can copy: "Couldn't create the event. Try again, or contact support with this reference: req_abc123." If the user contacts support, that ID lets engineering find the exact log line. The user does NOT see a stack trace.

Recovery: Retry; if it persists, support has the ID to investigate.

Permission failure: user lacks event-create permission

Trigger: a workspace member with a read-only role hits the Create button (or has it shown to them in error).

Ideally the button is hidden or disabled-with-explanation for read-only users — not shown and then 403'd on click. If the 403 does happen (race condition, just-downgraded role), the modal shows: "You don't have permission to create events in <workspace>. Ask your workspace admin to grant you the Organizer role." With the workspace admin's email if the user has Settings access, or a "Contact your admin" mailto if not.

Recovery: Out-of-band — the user has to get permission. Form input is discarded (no point preserving it).

Quota failure: workspace at event-count limit

Trigger: workspace plan caps at e.g., 50 events; user is creating the 51st.

The Create button shows a tooltip on hover before the user even clicks: "Your plan allows 50 events; you have 50." On click, the modal redirects to a "Upgrade to create more events" page with a clear comparison of the plans and a contact-sales link. The user's form input is preserved across the upgrade flow — when they return after upgrading, the modal reopens with their fields intact.

Recovery: Upgrade plan, or archive an old event to free a slot.

Copy failure: source event was deleted between picker and submit

Trigger: user picked a source event 2 minutes ago; another organizer deleted it; user clicks Create copy.

Server returns 404 with a specific error code (SOURCE_EVENT_NOT_FOUND). The modal does NOT just show a generic error; it goes back to the source-picker pane with the previously-selected source struck through and a message: "<Source name> was deleted. Pick a different event or start from scratch." The user's name and date input are preserved.

Recovery: Pick a different source, or switch to start-from-scratch (the chooser modal returns to its initial two-card state with a "we lost your source — start fresh?" hint).

Copy failure: source has data the user can't see (cross-permission)

Trigger: source event has guest-list rows that the current user, as an Organizer-but-not-Owner, can't read. They check "Copy guest list" anyway.

The server filters the copy to only the rows the user has read permission on. The success toast says "Created Annual Gala 2026 with 12 access types, 4 email designs, and 18 of 230 guests" — explicit about what was excluded. If the user expected all 230 guests, the discrepancy is visible immediately. Tooltip on the count explains: "212 guests were not copied because they were added by users you don't have access to."

Recovery: User can ask the original owner to recopy with their permission, or add the missing guests via import.

Copy partial failure: some sections succeed, some fail

Trigger: D1 batch fails halfway because one of the email designs references an asset that's been archived.

The server transaction rolls back the partial copy — there is no orphan event. The user sees an error: "Couldn't copy: one of the email designs in <Source name> references an asset that's no longer available. Uncheck Email designs and try again, or contact the source event's owner." The error is specific enough to act on.

Recovery: Uncheck the offending section, retry. Or copy without it and re-create the email design fresh in the new event.

Concurrent create with same name

Trigger: two organizers in the same workspace create events with the same name within seconds. The server allows duplicate names but generates unique slugs.

Both creates succeed. Both events get distinct slugs (e.g., annual-gala and annual-gala-2). The dashboard shows both events — they're not deduped — because they may genuinely be different events the two organizers are setting up in parallel. The user is NOT silently merged into the other organizer's event.

Recovery: No recovery needed — both succeed independently.

User cancels mid-form

Trigger: user opens the modal, types a name, then clicks Cancel or hits Esc.

The modal closes. If the user re-opens within the same session, the form is empty (no half-finished draft persisted). This is intentional — the modal is meant to be lightweight, not a multi-session draft surface. A user with a half-formed event idea should use the "Save as draft" pattern instead, which we don't expose at this step (events are always concretely created in this flow).

Recovery: Re-open the modal and re-fill if the user changes their mind.

Edge cases

Daylight Saving Time spans the event window

If start is in EST and end is in EDT (or vice versa), the form should display both date pickers in the user's chosen timezone with the DST transition rendered as a footnote: "This event spans a daylight saving change; clocks shift forward at 02:00 on Sunday." The stored UTC values are correct; the displayed local times are correct on both sides of the transition.

Source event with archived access types

Copy includes the access types but flags archived ones in the new event as active=0. The user sees them in the copy summary so they're not surprised by inactive types appearing in the new event later.

Source event was a multi-occurrence series

Copy handles only the single occurrence the user picked — not the whole series. The picker UI makes this clear by showing the occurrence's specific date alongside the series name. Out of scope for this branch: copying entire series.

User pastes a long event name (>200 chars)

The field caps at 200 characters with a visible character counter once the user passes 150. Pasting a longer string is truncated to 200 with a soft warning: "Truncated to 200 characters."

Mobile viewport (375×667)

The modal becomes a full-screen sheet on widths <640px. Date pickers use native mobile pickers (HTML5 datetime-local). The two-card chooser stacks vertically. The submit button is sticky to the bottom edge so the user doesn't have to scroll to find it after filling fields.

Page evaluation

Page / surfaceDiscoverabilityError UXLayoutOrientation
Create button (header) Visible at all admin paths above 1024px wide. Aria-label "Create event". If user lacks permission, button is disabled with explanatory tooltip — not hidden silently. No layout shift when the button is conditionally hidden on narrower viewports (collapses into the overflow menu). Always visible regardless of which admin sub-page the user is on.
Chooser modal Two cards side-by-side at ≥640px, stacked at <640px. Each card has an icon, a one-line description, and clickable hit area covering the full card. If user can't access "Copy from past" because they have no past events, that card is shown disabled with explanation, not hidden. Modal is centered, max-width 720px, with backdrop. Esc + click-backdrop close. Modal title "Create new event" or "Copy from past event" depending on selection. Breadcrumb-style.
Create form (Path A) Required fields marked with asterisk + screen-reader text. Submit button labeled "Create" not generic "Submit". Errors inline next to fields, multiple errors visible simultaneously, error text says what + how-to-fix. Single column at all viewports. Field labels above inputs (not floating, not placeholder-only). Form heading matches modal title. Required-field count visible in submit button: "Create (3 required fields remaining)" until valid.
Copy form (Path B) Source picker on left, copy options on right at ≥1024px. Stacked at <1024px with picker first. If source becomes invalid (deleted), user is returned to picker with the lost source struck and explained. No horizontal scroll. The copy-options checkbox list is grouped by section with section labels. Selected source shown at the top of the right pane with a "Change" link.
Event Setup hub (post-create) Setup checklist visible on the right; primary content is the event's quick-stats and most-recent activity. Per-card error states isolated; the rest of the hub remains usable. Two-column at ≥1024px. Event name and date are the page H1. document.title reflects the event name. Breadcrumb: Workspace > Events > <Event name> > Setup.

Color contrast

Field labels ≥4.5:1, error text ≥4.5:1 against its background. The "required" asterisk is red (≥3:1) but also has aria-label="required" so screen readers don't depend on color. Disabled "Copy from past event" card (when user has no past events) has its disabled state communicated with both opacity and a "no past events to copy from" caption — never opacity alone.

Keyboard navigation

Modal traps focus while open. Tab progresses through the two-card chooser, then form fields top-to-bottom, then submit + cancel. Esc closes. Date pickers are keyboard-accessible (arrow keys to navigate the calendar, Enter to select). Submit can be triggered by Cmd/Ctrl+Enter while any field is focused.

Screen reader

Modal opens with focus on the modal title for context. Aria-live "polite" region announces validation errors as they appear. The two-card chooser is a radiogroup; the cards are radio elements (visually styled as cards but semantically radios) so screen-reader users know one-of-two semantics.

Acceptance signals

  • POST /v1/admin/events returns 201 with the new event's id and slug.
  • Browser URL changes to /admin/events/<event-id>/setup.
  • D1 row exists in events with the submitted name, dates, timezone, capacity, and company_id matching the user's workspace.
  • For copy: D1 rows exist in the per-section tables (access_types, email_designs, etc.) for the new event, with foreign keys all pointing at the new event's id (no leaks to the source).
  • For copy: source event is unchanged — no rows mutated in the source.
  • Setup hub renders with the event's name and date in the H1.
  • Setup checklist shows "Add an access type · Configure registration email · Invite guests · Publish your event page" with the first item highlighted as the suggested next action.
  • Toast confirms creation (or copy summary including counts).
  • document.title is set to the event name + "· Setup".
  • No console errors at severity ≥ warn from Voyage origins.

Stable test attributes

This is the contract between the story and the Aperture admin code. Every data-test attribute below MUST be exposed by the code, in the named location, with the named purpose. Adding new attributes is fine; removing a listed attribute is a Ratchet violation requiring STORY-LOOSENING-APPROVED: in the commit message.

Visibility teeth. Each attribute below must be present in the document AND effectively visible per the runtime predicate (no display:none, visibility:hidden, opacity:0, zero bounding rect, off-screen positioning, or aria-hidden="true" on it or any ancestor). Where the row's purpose implies interactivity (a button, an input, a checkbox, a clickable card), it must additionally satisfy effectively interactive: no pointer-events:none, no disabled unless explicitly stipulated, focusable, not occluded at its center point. Hiding without removal is treated identically to removal — both require the loosening token. See PREDICATES.md for the full runtime definition.

data-testWherePurpose
create-event-ctaAdmin shell headerPrimary CTA opening the create-event modal
create-event-modalAdmin shell modal rootThe modal container; aria-role="dialog"
chooser-from-scratchInside create-event-modalTwo-card chooser, scratch path
chooser-copy-from-pastInside create-event-modalTwo-card chooser, copy path
create-event-form-scratchInside create-event-modalThe form for path A
create-event-form-copyInside create-event-modalThe form for path B
field-error-nameSibling of [name=name]Inline error; aria-live="polite"
field-error-startUtcSibling of [name=startUtc]Inline error; aria-live="polite"
field-error-endUtcSibling of [name=endUtc]Inline error; aria-live="polite"
field-error-timezoneSibling of [name=timezone]Inline error; aria-live="polite"
field-error-locationSibling of [name=location]Inline error; aria-live="polite"
form-warning-long-rangeInside create-event-form-scratchSoft warning when end-start > 365d
form-warning-long-range-suggestInside form-warning-long-rangeClick-to-apply suggested shorter range
form-error-bannerTop of either formAbove-form error for network/500/quota/copy-partial
form-error-request-idInside form-error-bannerCopy-pastable request id on 500
create-event-submitInside create-event-form-scratchForm A submit
create-copy-submitInside create-event-form-copyForm B submit
source-picker-searchInside create-event-form-copySearch box for source events
source-picker-resultInside create-event-form-copyEach row in the picker (multiple)
source-picker-result-deletedInside create-event-form-copyStruck-through marker when source is gone
copy-option-access-typesInside create-event-form-copyCheckbox; default checked
copy-option-email-designsInside create-event-form-copyCheckbox; default checked
copy-option-registration-questionsInside create-event-form-copyCheckbox; default checked
copy-option-guest-listInside create-event-form-copyCheckbox; default unchecked
copy-option-scheduleInside create-event-form-copyCheckbox; default checked when source has one
copy-option-brandInside create-event-form-copyCheckbox; default checked
event-created-toastAdmin shell toast rootPost-create success toast (path A)
event-copy-summary-toastAdmin shell toast rootPost-copy summary including counts (path B)
setup-hubPage /admin/events/:id/setupThe destination after create or copy
setup-checklistInside setup-hubGuided next-actions list
setup-checklist-item-highlightedInside setup-checklistThe single suggested next item
quota-upgrade-bannerTop of either formShown when POST returned 402

When the harness reports "selector [data-test=…] not found," the response is to add the attribute in the code, not to remove the probe. Probes are the Ratchet's teeth.

Agent test plan

Inherits the trunk's auth + shell-render setup. The agent harness runs the admin-shell-access trunk's setup steps first, then jumps to the home dashboard ready to exercise this branch's specific assertions.

Preconditions (consumed from trunk)
preconditions:
- trunkStoryId: admin-shell-access
  state: logged-in-as-organizer
  fixtures:
    user: [email protected]
    workspace: aperture
- shellPage: home-dashboard
  expect:
    - createEventCtaVisible: true
    - viewportSize: 1280x800
Path A — happy-path probe
1. click [data-test=create-event-cta]
2. expect modal[data-test=create-event-modal] visible, focus inside
3. click [data-test=chooser-from-scratch]
4. expect form[data-test=create-event-form-scratch] visible
5. fill [name=name] = "Pilot Event 2026 ${random}"
6. fill [name=startUtc] = "2026-06-15T14:00"
7. fill [name=endUtc]   = "2026-06-15T17:00"
8. fill [name=timezone] = "America/New_York"
9. fill [name=location] = "Test Venue"
10. click button[data-test=create-event-submit]
11. expect URL match /admin/events/[a-z0-9-]+/setup/
12. capture: { eventId: , slug: , snapshot }
13. assert toast[data-test=event-created-toast] visible
14. fetch /v1/admin/events/${eventId} via auth helper, assert row matches form input
15. assert document.title contains the event name
16. assert h1 = the event name
17. assert setup-checklist[data-test=setup-checklist] visible with first-item-highlighted
Path B — copy probe
preconditions:
- a fixture event "Source Pilot Event ${YYYY}" exists in the workspace with:
    - 3 access types
    - 2 email designs
    - 12 invited guests

1. click [data-test=create-event-cta]
2. click [data-test=chooser-copy-from-past]
3. expect picker[data-test=source-picker] visible
4. type into picker search: "Source Pilot"
5. click first picker result
6. expect copy-options pane visible with 6 checkboxes
7. assert checkbox[name=copy-access-types] checked-by-default with label "3 access types"
8. assert checkbox[name=copy-guest-list] unchecked-by-default with label "12 guests"
9. fill [name=name]    = "Copy of Source Pilot ${random}"
10. fill [name=startUtc] = "2026-06-15T14:00"
11. fill [name=endUtc]   = "2026-06-15T17:00"
12. click button[data-test=create-copy-submit]
13. expect URL match /admin/events/[a-z0-9-]+/setup/
14. capture: { eventId, snapshot }
15. assert toast text contains "3 access types and 2 email designs"
16. fetch /v1/admin/events/${eventId} and assert source-event-id NOT mutated
Failure-mode probes
For each failure mode in §failure-modes, the harness has a probe:
- validation-name-missing: submit empty form, assert inline error "Name is required" next to name field, no toast
- end-before-start: fill end-date before start-date, assert inline error on end-date with text "End time must be after start time"
- year-plus warning: fill range >365d, assert soft warning visible, suggested range link works
- network-failure: stub POST /v1/admin/events to network-fail, assert error message above form, form input preserved, retry includes idempotency-key header
- server-500: stub to 500 with X-Request-Id, assert error message includes request ID
- permission-403: log in as a read-only user (separate fixture), assert Create button disabled with tooltip
- quota-exceeded: stub to 402 with quota error, assert upgrade page link, form input preserved across redirect
- copy-source-deleted: stub source-fetch to 404 mid-flow, assert picker re-opened with struck-through source
- copy-cross-permission: copy from a source with private guests, assert toast says "X of Y guests copied"
- copy-partial-failure: stub to 500 mid-batch, assert no orphan event row
- concurrent-name-collision: simulate two parallel creates, assert both succeed with distinct slugs
- modal-cancel: open modal, type, hit Esc, re-open, assert form is empty
Page-evaluation assertions
At the chooser modal, create form, copy form, and post-create setup hub:
- axe-core: no violations of impact 'serious' or 'critical'
- color contrast: body ≥4.5:1, error text ≥4.5:1, required-asterisk ≥3:1 (color) AND aria-label="required"
- focus-trap: tab through modal, assert focus stays inside; assert Esc closes
- focus-return: close modal, assert focus returns to the create-event-cta trigger
- screen-reader: modal title is the focus on open; aria-live polite region announces validation errors
- mobile-viewport (375x667): two-card chooser stacks; modal becomes full-screen sheet; submit is sticky-bottom
- document.title: per-page-distinct, includes event name post-create