Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ D1 database names are duplicated between the wrangler files and `build-manifest.
| New page added for one conference only | `conference/content/pages/<slug>.mdx` (auto-discovered by mdx-bundles) |
| Hero blurb on the homepage | `conference/content/pages/_home-hero.mdx`, referenced by `manifest.homepage.heroBlurbSlug` |
| Country / land acknowledgement | `conference/content/pages/_acknowledgement.mdx`, referenced by `manifest.homepage.acknowledgementSlug` |
| Mobile app download page (`/app`) | `manifest.mobileApp` (iOS + Android URLs). Omit to 404 the route |
| Mobile app download page (`/app`) | `manifest.mobileApp` (iOS + Android URLs). Omit to 404 the route; set `mobileApp.retired` to 404 it while keeping `/app-config` alive |
| New manifest field | `core/libs/conference-config/src/manifest.ts` (interface) + every fork's `conference/manifest.ts` (value) |
| D1 migration | `core/website/migrations/` (schema is core; data is per-fork at runtime) |
| Sessionize/Tito secret | env var (set per environment via `wrangler secret put`) |
Expand All @@ -112,6 +112,10 @@ Slugs starting with `_` are excluded from the sitemap and the catchall route (th

Mobile-app advertising follows a different pattern — `manifest.mobileApp` either exists (route renders) or doesn't (route + JSON endpoint both 404). No fallback rendering, because pointing visitors at a non-existent app is worse than no link at all.

Retiring an app is a third state, and it splits those two routes apart. Once a fork stops maintaining its app there are still copies installed on people's phones polling `/app-config` on launch, so 404ing that endpoint breaks them rather than retiring them. Setting `manifest.mobileApp.retired` keeps the store URLs in the manifest (the endpoint needs the rest of the block) while `/app` starts 404ing like a fork with no app at all. `/app-config` is untouched — a retired app receives a byte-identical payload, which a test asserts. Don't retire an app by deleting `mobileApp` — that takes the installed copies down with it.

**`retired` is deliberately a bare flag, not a place for banner copy.** Telling a retired app's remaining users something is what `/app-announcements` already does — editable at `/admin/settings`, stored in D1, and, decisively, *already understood by the installed builds*. A new config field would only reach a build shipped after it was added, which for an app nobody is rebuilding is nobody. Note the announcement slot is a single current message per year, so a standing retirement notice and a day-of update compete for it.

When a new per-fork extension point is needed, the same pattern applies: add an optional manifest field in `core/libs/conference-config/src/manifest.ts`, have the core component check + branch on it, ship an `.mdx` in `conference/content/pages/` if the fork wants the content. Avoid baking conference-specific strings into components.

## Scripts
Expand Down
6 changes: 6 additions & 0 deletions conference-stub/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,10 @@ export const conferenceManifest: ConferenceManifest = {
// mobileApp intentionally left undefined — DevConf Example doesn't have
// a mobile app, so /app returns 404. This shows the opt-in pattern:
// forks add mobileApp only after publishing to the app stores.

// runsheets intentionally left undefined — the volunteer run sheet reads
// a fork's own Jira board (site, project, custom field ids and the
// committee's label vocabulary), none of which a stub can invent, so
// /runsheets returns 404 here. A fork adds it once its board exists;
// see /conference/config/runsheets.ts in DDD Perth's repo.
}
5 changes: 5 additions & 0 deletions libs/conference-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
ConferenceVenue,
ConferenceYear,
DateTimeRange,
MajorSponsorTier,
MinorSponsorTier,
SessionData,
SessionizeConferenceSessions,
Expand All @@ -21,6 +22,8 @@ export type {
YearSponsors,
} from './types.ts'

export { MAJOR_SPONSOR_TIERS, MINOR_SPONSOR_TIERS, SPONSOR_TIERS } from './types.ts'

export type {
Brand,
ConferenceBuildManifest,
Expand All @@ -35,6 +38,8 @@ export type {
MeetTheExpertsSlotConfig,
NavConfig,
NavItem,
RunsheetsConfig,
RunsheetsJiraConfig,
Socials,
SpeakerDinnerConfig,
SpeakerPortalChecklistConfig,
Expand Down
94 changes: 94 additions & 0 deletions libs/conference-config/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,13 +241,34 @@ export type NavConfig = NavItem[]
* Optional mobile app advertising. When set, /app is reachable and links to
* the app stores. When absent, /app returns 404 (most forks don't have a
* mobile app and shouldn't surface a dead route).
*
* `retired` splits those two behaviours apart. A fork that has stopped
* maintaining its app still has copies installed on people's phones, and
* those copies poll /app-config on launch — so the endpoint must keep
* answering even once the website stops advertising the app. Set `retired`
* and /app 404s like a fork with no app at all, while /app-config continues
* to serve and carries a `notice` telling the app to point its users back
* at the website.
*/
export interface MobileApp {
iosUrl: string
androidUrl: string
/** Bundle ID surfaced via /app-config to the mobile app itself. */
iosBundleId?: string
androidBundleId?: string
/**
* Set once the app is no longer maintained. Stops the website
* advertising it (/app 404s, store links disappear) without cutting off
* the installed copies that still depend on /app-config.
*
* Deliberately a bare flag and not a place to put banner copy. Telling
* the app's remaining users anything is what `/app-announcements`
* already does, and that channel has the decisive advantage: the
* installed builds already know how to render it. A new config field
* would only reach a build shipped after it was added — which, for an
* app nobody is rebuilding, is no one.
*/
retired?: boolean
}

/**
Expand Down Expand Up @@ -322,6 +343,16 @@ export interface SponsorPortalJiraConfig {
* hidden until the committee fills it in.
*/
assetUploadUrl?: string
/**
* Which room a room sponsor's sponsorship covers. Committee-assigned,
* so the portal only displays it — and it feeds the venue's exhibitor
* spreadsheet and the public site's `roomName`.
*
* The field may carry a Jira default, which would make every sponsor
* look assigned. Treat a value equal to `unassignedRoomValue` as "not
* yet decided" so the portal stays honest until someone chooses.
*/
exhibitorRoom?: string
/**
* Paragraph field the sponsor's quote/blurb is pushed into on every
* portal save (sponsor-owned — the portal's value overrides Jira's).
Expand Down Expand Up @@ -390,6 +421,17 @@ export interface SponsorPortalJiraConfig {
* these, the portal leaves it alone rather than dragging it backwards.
*/
assetsPendingOptionIds: string[]
/**
* The `exhibitorRoom` value meaning "no room assigned yet".
*
* Jira single-selects can carry a default, so every issue would read back
* as the first room whether or not anyone chose it. Naming that value here
* lets the portal tell "assigned" from "untouched" and stay quiet until the
* committee actually decides — a sponsor sent to the wrong room is worse
* than one told nothing yet. Omit when the field has no default, and every
* non-empty value counts as assigned.
*/
unassignedRoomValue?: string
/**
* The remaining workstream status flips, each triggered by the sponsor
* finishing the matching part of the portal:
Expand Down Expand Up @@ -585,6 +627,56 @@ export interface MeetTheExpertsConfig {
* The fork's /conference/manifest.ts exports an object of this shape as
* `conferenceManifest`.
*/
/**
* Jira wiring for the public volunteer run sheet. Like the sponsor portal's
* config, every value here is fork-owned: the Jira site, the project, the
* custom field ids and the committee's label vocabulary all differ per fork,
* so none of it can live in core. Credentials are NOT here; they're host
* secrets (wrangler `JIRA_API_EMAIL` / `JIRA_API_TOKEN`).
*/
export interface RunsheetsJiraConfig {
/** Jira site, e.g. "https://dddperth.atlassian.net" */
baseUrl: string
/**
* JQL selecting the run sheet items to display. Written out in full
* rather than assembled from parts, because which issue type, time
* bracket or board convention marks "the run sheet" is a committee
* decision, not something core should presume.
*/
jql: string
/** Custom field ids on the run sheet issue type. */
fields: {
/** Datetime field for when the item starts. */
startTime: string
/** Datetime field for when the item ends. */
endTime: string
/** Labels field naming the location(s) the item happens at. */
location: string
/** Labels field naming the volunteer team(s) responsible. */
team: string
/** URL field linking to the role instructions (a Confluence page here). */
roleInstructions: string
}
}

/**
* The public volunteer run sheet at /runsheets. Omit for forks without a
* volunteer Jira board — the route returns 404 then.
*
* The page is deliberately anonymous, so the label maps below are also the
* page's allowlist: the `$filter` path segment is only ever matched against
* these keys, and anything else is rejected rather than reaching the JQL.
* A label missing from these maps is still *displayed* (it falls back to the
* raw Jira label) — it just can't be filtered on.
*/
export interface RunsheetsConfig {
jira: RunsheetsJiraConfig
/** Jira "Volunteer Team" label -> display name, e.g. `'team-1': 'Team 1'`. */
teamLabels: Record<string, string>
/** Jira "Location" label -> display name, e.g. `'loc-cygnet-room': 'Cygnet Room'`. */
locationLabels: Record<string, string>
}

export interface ConferenceManifest {
public: ConferenceConfigPublic
socials: Socials
Expand All @@ -596,6 +688,8 @@ export interface ConferenceManifest {
homepage?: HomepageContentSlots
/** Mobile app config. Omit for forks without an app — /app returns 404 then. */
mobileApp?: MobileApp
/** Public volunteer run sheet. Omit for forks without one — /runsheets returns 404 then. */
runsheets?: RunsheetsConfig
/** Sponsor portal config. Omit for forks without one — /portal returns 404 then. */
sponsorPortal?: SponsorPortalConfig
/** Speaker portal config. Omit for forks without one — /speaker-portal returns 404 then. */
Expand Down
39 changes: 36 additions & 3 deletions libs/conference-config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,46 @@ export interface Sponsor {
}

/**
* Tiers rendered outside the headline platinum/gold/silver/bronze ladder —
* sponsors who fund a specific thing rather than buying a tier.
* The headline tier ladder, in render order.
*/
export const MAJOR_SPONSOR_TIERS = ['platinum', 'gold', 'silver', 'bronze', 'digital'] as const

/**
* Tiers rendered outside the headline ladder — sponsors who fund a specific
* thing rather than buying a tier — in render order, with the default heading
* each one gets.
*
* By default these all render together under one "Other Sponsors" heading.
* A fork with enough of them to be worth separating can set
* `features.separateOtherSponsorTiers` to give each its own heading, and
* rename any of them via `public.sponsorTierLabels`.
*/
export type MinorSponsorTier = 'community' | 'coffeeCart' | 'quietRoom' | 'venue' | 'prize' | 'keynotes'
export const MINOR_SPONSOR_TIERS = [
{ tier: 'community', defaultLabel: 'Community' },
{ tier: 'coffeeCart', defaultLabel: 'Coffee Cart' },
{ tier: 'quietRoom', defaultLabel: 'Quiet Room' },
{ tier: 'venue', defaultLabel: 'Venue' },
{ tier: 'prize', defaultLabel: 'Prize' },
{ tier: 'keynotes', defaultLabel: 'Keynotes' },
{ tier: 'inKind', defaultLabel: 'In Kind' },
] as const

export type MajorSponsorTier = (typeof MAJOR_SPONSOR_TIERS)[number]
export type MinorSponsorTier = (typeof MINOR_SPONSOR_TIERS)[number]['tier']

/**
* Every tier key a year config can carry, in render order. `room` is last
* because it's the one tier whose entries need an extra field.
*
* Sponsor tooling derives its tier list from this rather than keeping its own
* copy — three hand-maintained copies had already drifted from the type
* (missing `venue`/`prize`, carrying a `lunch` tier that never existed).
*/
export const SPONSOR_TIERS = [
...MAJOR_SPONSOR_TIERS,
...MINOR_SPONSOR_TIERS.map(({ tier }) => tier),
'room',
] as const satisfies readonly (keyof YearSponsors)[]

export interface YearSponsors {
platinum?: Sponsor[]
Expand All @@ -52,6 +83,8 @@ export interface YearSponsors {
prize?: Sponsor[]

keynotes?: Sponsor[]
/** Goods or services provided instead of cash (lighting, venue hire, AV). */
inKind?: Sponsor[]
room?: Array<Sponsor & { roomName: string }>
}

Expand Down
Loading
Loading