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.
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
| Field | Type | Purpose |
id | int PK | |
name | string (required) | Display name. |
description | string? | Short blurb for the vendor card. |
imagePath | string? | Logo / photo. |
url | string? | Website or social link. |
isActive | bool | Retired vendors deactivate, never delete — registrations and assignments are history. |
| audit | IAuditable | |
cms.VendorEventSeries — the event (domain root)
| Field | Type | Purpose |
id | int PK | |
name | string (required) | "City Market 2026". |
description | string? | Intro copy for the public surface. |
facilityContactId | int? FK (non-unique) | Optional host facility. Required only for spatial mapping — a series with no facility is dates + vendors without a map. |
isPublished | bool | Gates the public overlay; defaults off during data entry. |
imagePath | string? | 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. |
socialImageConfig | string? (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. |
| audit | IAuditable | |
cms.VendorEventDate — a series date
| Field | Type | Purpose |
id | int PK | |
vendorEventSeriesId | int FK (cascade) | Parent series. |
date | date (DateOnly) | Facility-local calendar date. Unique per series. |
isCancelled | bool | Domain-level cancellation — the viewer shows the date as cancelled; assignments persist for the record. |
| audit | IAuditable | |
cms.VendorEventRegistration — series ↔ vendor membership
| Field | Type | Purpose |
id | int PK | |
vendorEventSeriesId | int FK (cascade) | Parent series. |
vendorId | int FK (restrict) | The registered vendor. Unique per (series, vendor). |
| audit | IAuditable | |
cms.VendorEventAssignment — the spatial junction (Facility View side)
| Field | Type | Purpose |
id | int PK | |
vendorEventDateId | int FK (cascade) | The series date this placement belongs to — deleting a date removes its placements. |
vendorId | int FK (restrict) | Who. Must be registered to the series (API-enforced, not a DB constraint — registration stays soft). |
facilitySpaceId | int FK (restrict) | The stall — a space of the series' facility, drawn at stall granularity (hidden highlight-only zones work). |
| audit | IAuditable | |
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
| Option | Verdict |
| 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)
| Route | Method | Returns / does |
/vendors | GET / POST / PUT | City-wide vendor CRUD (no delete — deactivate). |
/vendor-events | GET / POST | All series with stats (facility, next date, vendor/date counts) + create. |
/vendor-events/{seriesId} | GET / PUT / DELETE | Series detail (dates + registrations inline) + update + delete (cascades children). |
/vendor-events/{seriesId}/dates (+ /{dateId}) | POST / PUT / DELETE | Add dates (bulk), edit (cancel/uncancel, move), remove (cascades that date's assignments). |
/vendor-events/{seriesId}/registrations (+ /{registrationId}) | POST / DELETE | Register / withdraw a vendor. Withdrawal is blocked while the vendor holds assignments in the series (explicit unmap first). |
Routes — spatial mapping (Facility View admin surface)
| Route | Method | Returns / does |
/facility-view/{id}/vendor-events/{seriesId}/dates/{dateId}/assignments | PUT | Bulk-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= | GET | Date-grouped placements for the editor (unpublished included). |
Routes — public viewer
| Route | Method | Returns / does |
/facility-view/{id} (existing payload) | GET | Capability signal: the facility's published series (id, name, next non-cancelled date). |
/facility-view/{id}/vendor-events/{seriesId} | GET | Date-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 list → series 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 today | What 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
- 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).
- API — module CRUD (series/dates/registrations), reworked assignment + public endpoints.
- Portal — the Vendor Events module page; Facility View admin slims to the assignment editor.
- PWA — consume the reshaped read (empty/cancelled dates render; drop the calendar sourceKey row-matching).
6 · Deferred
| Item | Notes |
| Primary entry point in the viewer | The 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 applications | A public Form creating pending registrations for staff approval — the registration table is the landing spot. |
| Registration status / stall preferences | Columns on VendorEventRegistration when self-service arrives; not before. |
| Vendor categories / filtering; season cloning | Add 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. |