Module

Vendor Events

A module for recurring vendor events — "City Market 2026": the event series, its dates, and its registered vendors. The series is the domain root; Facility View consumes it for the one thing that is facility-scoped — the spatial mapping of date + vendor + facility space, rendered on the facility map so citizens can see which vendors are present each week and where.

Status Draft
Last updated
Related ModuleFacility View · Programs
LayerCalendar & Time · Location

Module Definition

The domain root is the event series — an ongoing or seasonal vendor event that owns two child lists: its dates and its registered vendors. Vendors themselves are city-wide identities, reusable across series through per-series registration. The series is optionally tied to a facility; when it is, Facility View can map each date's registered vendors onto the facility's spaces and the public viewer renders "who is where this week." The module mirrors the Boards shape: a detail page whose children (dates, registrations) parallel a board's meetings and members.

Within Facility View this remains the third overlay shape — event-scoped, capability-anchored (activates where published series exist, regardless of viewerType), internally-owned data. Only the assignment (the spatial junction) lives on the Facility View side; everything else belongs to this module.

What is in scope

  • Vendor identity (cms.Vendor) — city-wide, staff-authored.
  • The event series (cms.VendorEventSeries) with its dates (cms.VendorEventDate) and vendor registrations (cms.VendorEventRegistration).
  • The spatial junction (cms.VendorEventAssignment): date + vendor + facility space, authored in Facility View's admin surface.
  • A Vendor Events module page in the my/ portal (Boards-style detail: dates + vendors as child sections).
  • The public viewer surface: date-scoped vendor list ↔ space highlight on the facility map.

What is out of scope (for now)

  • Vendor self-service — applications become pending registrations via Forms/workflow later; V1 is staff-managed.
  • Registration, payments, or stall fees — no money movement, no Programs involvement.
Design principle — UI home follows data scope. City-wide and event-scoped records (vendors, series, dates, registrations) are administered in the Vendor Events module page. Facility-scoped records (the assignments) are administered on the facility's Facility View admin page. Neither schema references FacilityViewConfig — the overlay stays capability-anchored, so the host facility's viewer type can change freely.

1 · Data Model

Five cms tables, all human-edited and IAuditable. The series owns dates and registrations; the assignment joins a date row to a vendor and a space.

cms.Vendor — city-wide vendor identity

FieldTypePurpose
idint PK
namestring (required)Display name.
descriptionstring?Short blurb for the vendor card.
imagePathstring?Logo / photo.
urlstring?Website or social link.
isActiveboolRetired vendors deactivate, never delete — registrations and assignments are history.
auditIAuditable

cms.VendorEventSeries — the event (domain root)

FieldTypePurpose
idint PK
namestring (required)"City Market 2026".
descriptionstring?Intro copy for the public surface.
facilityContactIdint? FK (non-unique)Optional host facility. Required only for spatial mapping — a series with no facility is dates + vendors without a map.
isPublishedboolGates the public overlay; defaults off during data entry.
imagePathstring?Series image (logo / branding graphic), set by the upload endpoint like Vendor.imagePath. Rendered in the social-image footer (§4); available to future series surfaces.
socialImageConfigstring? (JSON)Saved layout defaults for the social-image generator (preset, map transform, list side, font size). Opaque to the API — owned and interpreted by the portal composer; one column while the knob set settles.
auditIAuditable

cms.VendorEventDate — a series date

FieldTypePurpose
idint PK
vendorEventSeriesIdint FK (cascade)Parent series.
datedate (DateOnly)Facility-local calendar date. Unique per series.
isCancelledboolDomain-level cancellation — the viewer shows the date as cancelled; assignments persist for the record.
auditIAuditable

cms.VendorEventRegistration — series ↔ vendor membership

FieldTypePurpose
idint PK
vendorEventSeriesIdint FK (cascade)Parent series.
vendorIdint FK (restrict)The registered vendor. Unique per (series, vendor).
auditIAuditable

cms.VendorEventAssignment — the spatial junction (Facility View side)

FieldTypePurpose
idint PK
vendorEventDateIdint FK (cascade)The series date this placement belongs to — deleting a date removes its placements.
vendorIdint FK (restrict)Who. Must be registered to the series (API-enforced, not a DB constraint — registration stays soft).
facilitySpaceIdint FK (restrict)The stall — a space of the series' facility, drawn at stall granularity (hidden highlight-only zones work).
auditIAuditable

Constraints: unique (vendorEventDateId, facilitySpaceId) — one vendor per stall per date; a vendor may hold two stalls on a date. Cascade chain assignment → date → series; vendor and space FKs are Restrict so history survives.

Options considered

OptionVerdict
Series as domain root (dates + registrations as children) Chosen Matches the real workflow (create the event → register vendors → enter dates → map spatially) and the Boards precedent (meetings + members). Replaced the first-pass shape where the series was a thin grouping row and dates existed only implicitly on assignments — that made "the market's dates" depend on assignments existing and pushed cancellation out to a calendar lookup.
Assignment keyed by raw date column The first-pass shape. Re-keyed to vendorEventDateId: referential integrity (a placement can't exist on a non-date), date deletion cascades placements, and cancellation is a flag on the date row every consumer shares. Rejected.
Assignment FK → registration (instead of vendor) Would hard-enforce membership but couples placement history to registration lifecycle (a withdrawn registration would cascade or block). Membership is validated at the API instead; the vendor link stays direct. Rejected.
Dates as calendar events CalendarEvent rows are reconciliation-owned projections (occurrences expanded at read time; ids churn under SourceKey upserts) and nothing in the platform treats them as domain parents. Domain rows here; calendar gets projections (§2). Rejected.
Stalls as Programs / bending FacilitySpaceLibrary / a "market" viewer type / the "layer above facilities" app All rejected in the first design pass — calendar fan-out noise + Exchange coupling; wrong generalization axis; the market describes an event, not the facility; the request was facility-shaped. Reasoning preserved in git history of this page.

2 · Calendar Relationship

Dates are domain rows; the calendar is downstream. The integration is publish, not read: a series with a calendar target (VendorEventSeries.CalendarId, staff-set) pushes its dates through the calendar-publish service the way program sessions do — VendorEventCalendarSyncService, one source per series (sourceKey "vendorEventSeries-{id}"), reconciled on every series/date mutation — so the market appears on the calendar (and in Exchange) from the same domain truth the viewer renders. Domain → calendar, never calendar → domain.

Dates are day-only domain rows, so they publish as all-day events titled with the series name; cancelled dates come off the calendar (the domain viewer keeps showing the cancelled state), and an unpublished series — or a cleared/re-pointed target — has its footprint removed. The published rows are the calendar's discovery surface only: their sourceKey routes any tap in the City Viewer (city Calendar tab or facility Events tab) into the series' own date-scoped vendor view (?facility=N&tab=events&series=S&date=D deep link) — the domain view stays the destination, so publishing never costs the custom market UI. This sourceKey-routing pattern is reusable for any domain that publishes to the calendar (e.g. program sessions → registration).

3 · API & Endpoints

Routes — Vendor Events module (staff, in the my/ portal)

RouteMethodReturns / does
/vendorsGET / POST / PUTCity-wide vendor CRUD (no delete — deactivate).
/vendor-eventsGET / POSTAll series with stats (facility, next date, vendor/date counts) + create.
/vendor-events/{seriesId}GET / PUT / DELETESeries detail (dates + registrations inline) + update + delete (cascades children).
/vendor-events/{seriesId}/dates (+ /{dateId})POST / PUT / DELETEAdd dates (bulk), edit (cancel/uncancel, move), remove (cascades that date's assignments).
/vendor-events/{seriesId}/registrations (+ /{registrationId})POST / DELETERegister / withdraw a vendor. Withdrawal is blocked while the vendor holds assignments in the series (explicit unmap first).

Routes — spatial mapping (Facility View admin surface)

RouteMethodReturns / does
/facility-view/{id}/vendor-events/{seriesId}/dates/{dateId}/assignmentsPUTBulk-replace one date's placements — rows of (facilitySpaceId, vendorId). Validates the series belongs to the facility, spaces belong to the facility, vendors are registered.
/facility-view/{id}/vendor-events/{seriesId}/assignments?from=&to=GETDate-grouped placements for the editor (unpublished included).

Routes — public viewer

RouteMethodReturns / does
/facility-view/{id} (existing payload)GETCapability signal: the facility's published series (id, name, next non-cancelled date).
/facility-view/{id}/vendor-events/{seriesId}GETDate-scoped view: series + dates in window (default: next upcoming date), each with its cancellation flag and placements (vendor card fields + space). Dates render even with no placements yet.

4 · User Interface

Vendor Events module page (my/ portal — Boards pattern)

  • Series listseries detail (header/info card: name, facility, published) with two child sections, each rows + drawer forms: Dates (add/cancel/remove; bulk "add a weekly run" convenience) and Vendors (register from the city-wide list / withdraw).
  • Vendors section — the city-wide vendor directory with add/edit drawers, on the same module page (list view), since registration picks from it.

Social media image (per series date, on the detail page)

  • Each date row offers Social image — a drawer composing a post-ready PNG: header (series name + "Vendor Map — date") → numbered vendor list beside the floor-plan map (badges match list numbers), with the series image overlaid on the map's bottom corner opposite the list — an overlay sized by its own knob, not a reserved band, so the map keeps the full height. Platform pixel presets (1080×1080, 1080×1350, 1200×630, 1080×1920), drag/zoom/rotate map framing, aerial-underlay and facility-boundary toggles (indoor venues cover the underlay; outdoor boundaries are usually just parcel lines), list side + font-size knobs; Save as series default persists the layout so week N is one click. Outputs: copy/download PNG, generated alt text, and a plain-text caption carrying the full numbered vendor list — the caption is the real accessibility artifact (platform alt-text fields are short), and doubles as post copy.
  • Rendering is entirely client-side canvas, all native draws. The map layer is the exportSafe floor-plan SVG (labels off, styles inlined, underlay inlined as a data URI); stall badges, header text, and the vendor list are drawn with the canvas API so rotation keeps badges upright and no server-side rasterizer dependency is taken on. Two rejected alternatives: a server-side renderer (.NET SVG rasterization is a heavy dependency for an image the browser can compose from data it already has) and staff-authored header/footer HTML bands rasterized via foreignObject snapshot — built first, then removed: the snapshot needs valid XHTML, inline-only styles, and CORS-readable images, a fragility class staff authoring can't be expected to satisfy. Fixed header text + one series image covers the actual need.
  • Canvas-bound image bytes come through the API (underlay: GET facility/{id}/floor-map/{floor}/underlay/image; series image: GET vendor-events/{id}/image): the static host serves no CORS headers, so drawing its bytes would taint the canvas and break copy/download. Revisit if the static server ever ships Access-Control-Allow-Origin for /media.

v1 limits & anticipated asks

The governing constraint behind all of these: every element must be a native canvas draw from CORS-readable bytes — no arbitrary HTML (the foreignObject path was tried and removed, above). Within that, each ask below is tractable:

Limit todayWhat lifting it takes
Header font — fixed system stack (Segoe UI fallback); brand fonts unused. Cheap. Canvas text can use any font the portal document has loaded, and the portal already loads the self-hosted brand fonts — a font-family knob in socialImageConfig plus a document.fonts.load() before drawing. No API work.
Header composition — fixed text (series name / "Vendor Map — date"), band only. Free-text override fields are easy (native text draws). Overlaying the header on the map (like the logo) needs outlined/haloed text for contrast over aerial imagery — strokeText under fillText, small.
One image slot — the series image; no separate department/city logo. Either bake the logo into the uploaded series image (works today), or add a second image slot repeating the series-image pattern (upload + API-served bytes + placement knob). Any image source must be API-served or a data URI — a raw static-host URL taints the canvas.
No QR code on the image. The existing generator (GET cms/webpage/{id}/qr-code, QRCoder, PNG, optional embedded city logo) is webpage-bound. A composer overlay needs either "pick a target webpage" in the drawer, or a small generalized any-URL QR endpoint (QRCoder is already a dependency) — plus a corner-placement knob. QR modules must stay high-contrast on a light patch; don't drop one straight onto aerial imagery.
Single-column vendor list; the font-size knob is the overflow valve (a warning shows when the list clips). Two-column or auto-fit layout when a real market date proves the knob insufficient.
Light palette only — the export ignores the portal theme. Deliberate: posts render outside the portal. A dark variant would be a palette toggle, not a theme dependency.

Facility View admin — spatial mapping only

  • For a facility hosting published/unpublished series: the assignment editor — pick a series date (from its date rows), then a display list of that date's placements beside the facility's interactive floor plan, cross-highlighted (library-workspace pattern): a row focuses its space; a free space opens an add drawer pre-filled with that space, an occupied one jumps to its row. Add/edit opens a drawer (space + vendor); every action — add, edit, remove, copy-previous — persists immediately via the bulk-replace endpoint, no separate "save date" step. The drawer's vendor field can register an existing directory vendor or create a brand-new one inline (embeds the module's own vendor/registration forms) so a last-minute vendor never requires a trip to the Vendor Events page. Nothing else from this domain is administered here.

Public viewer

  • Find — vendor search beside space search, mirroring the library's space + catalog composition: a mapped vendor is a search result carrying its upcoming dates as chips (the same shape as the library's edition chips); picking a date shows that date's stall and highlights it on the map.
  • Events — a banner per published series into the date-scoped vendor view (date chips → vendor list ↔ stall highlight; cancelled dates flagged).
  • Space detail — a "who's here" block on mapped spaces.
  • Vendor cards (search results, event view, who's-here) render the vendor's image where one is set.

5 · Build Sequence

  1. Schema reshape — add VendorEventDate + VendorEventRegistration, series facility → nullable, drop calendarSourceKey, re-key assignments to the date row. Replacement migration (tables were empty at reshape time).
  2. API — module CRUD (series/dates/registrations), reworked assignment + public endpoints.
  3. Portal — the Vendor Events module page; Facility View admin slims to the assignment editor.
  4. PWA — consume the reshaped read (empty/cancelled dates render; drop the calendar sourceKey row-matching).

6 · Deferred

ItemNotes
Primary entry point in the viewerThe request framed the market as a primary use case, but v1 reaches it via Events tab → series banner — two taps deep. Options when refining: promote a published series to a launcher on the viewer's landing state (e.g. a card over the map or a dedicated segment on market days), and/or surface it on the facility directory tile. Decide after observing real use.
Vendor self-service applicationsA public Form creating pending registrations for staff approval — the registration table is the landing spot.
Registration status / stall preferencesColumns on VendorEventRegistration when self-service arrives; not before.
Vendor categories / filtering; season cloningAdd when vendor count or a second season demands them.
Overlay composition (multiple viewer types per facility)Unchanged from the first pass — trigger when a facility needs two type-anchored overlays.