diff --git a/README.md b/README.md index a0a90925..a304b8bf 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,26 @@ docker compose exec app npm run db:seed machine below uses `bun`, because `bun.lock` is the lockfile — `npm install` would ignore it and resolve a different tree.) -The seed creates two idempotent cases: - -- **Cicero Forum** — a fictional Roman-themed conference with 14 submissions mid-review, 7 - historically inspired speakers, 4 tracks, 3 rooms, and a two-day agenda with gaps still in it. -- **The First Settlement** — a Roman Senate-themed programme inspired by the sessions of - 13–16 January 27 BCE, with motions, consular review, a partly scheduled agenda, and outstanding - speaker tasks. - -Run it twice and you get the same two events, not four. +The seed creates four idempotent cases. The first three are one conference at three scales, so you +can see what a screen does under load without writing a fixture for it: + +- **Cicero Forum** (`/demo`) — **the default sample event**, at the medium size: 96 submissions + mid-review, 45 speakers, 4 tracks, 5 rooms, and a two-day agenda with gaps still in it. The first + 14 proposals and 7 speakers are hand-written and are what you meet first; the rest is generated, + and is what gives the review queue and the agenda grid some weight. +- **Provincial Assembly** (`/demo-small`) — the same conference sized like a meetup: 18 submissions, + 8 speakers, one day, two rooms. +- **Imperial Congress** (`/demo-large`) — and sized like a large one: 384 submissions, 180 speakers, + three days, ten rooms. Open this when the question is whether a list paginates, a grid stays + readable, or a query falls over. +- **The First Settlement** (`/first-settlement`) — a Roman Senate-themed programme inspired by the + sessions of 13–16 January 27 BCE, with motions, consular review, a partly scheduled agenda, and + outstanding speaker tasks. + +Every generated speaker gets a procedurally drawn portrait and an address on an IANA-reserved +domain (`@demo-large.example` and friends), so nothing the seed writes can receive mail. + +Run it twice and you get the same four events, not eight. ## Local development @@ -110,7 +121,7 @@ have their own optional settings. Apply migrations before serving a new applicat | `bun run db:check` | Validate Drizzle migration snapshots | | `bun run db:migrate` | Apply migrations | | `bun run db:migrate:remote` | Apply migrations to a deployment target; ignores `.env`, rejects localhost | -| `bun run db:seed` | Seed both demo conferences (idempotent) | +| `bun run db:seed` | Seed all four demo conferences, `demo` included (idempotent) | | `bun run db:seed:first-settlement` | [Plan or seed only the Roman demo](docs/first-settlement-seed.md) | | `bun run cf:deploy` | Build and deploy to Cloudflare Workers | diff --git a/db/seed-publication-invariants.test.ts b/db/seed-publication-invariants.test.ts index cfe90ca8..a51a6632 100644 --- a/db/seed-publication-invariants.test.ts +++ b/db/seed-publication-invariants.test.ts @@ -6,16 +6,27 @@ function source(path: string): string { return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8'); } +/** + * `sized-roster.ts` writes a second roster onto the same events — the generated crowd that brings + * each sample event up to its size profile. It is held to the same two invariants, because a + * gallery of forty invited speakers is exactly as empty as a gallery of seven. + */ +const SEEDS = [ + './seed.ts', + './seeds/first-settlement.ts', + './seeds/sized-roster.ts', +] as const; + function participantInsert(path: string): string { const match = source(path).match( - /const participants = await db\s+\.insert\(participant\)[\s\S]*?\.returning\(\);/, + /const \w+ = await db\s+\.insert\(participant\)[\s\S]*?\.returning\(\);/, ); expect(match, `${path} should contain its participant fixture insert`).not.toBeNull(); return match![0]; } describe('public speaker seed invariants', () => { - it.each(['./seed.ts', './seeds/first-settlement.ts'])( + it.each(SEEDS)( 'marks the public profiles in %s as confirmed', (path) => { expect(participantInsert(path)).toContain("workflowStatus: 'confirmed' as const"); @@ -28,13 +39,25 @@ describe('public speaker seed invariants', () => { * `speakerHeadshotPath` answers null for it, and the roster quietly renders initials. Assert the * wiring, since no type is going to. */ - it.each(['./seed.ts', './seeds/first-settlement.ts'])( + it.each(SEEDS)( 'gives the public profiles in %s a generated headshot', (path) => { expect(participantInsert(path)).toMatch(/headshotFileId: profileArt\.get\(/); }, ); + /** + * The sized siblings build their call for speakers from the shared helpers rather than a third + * hand-written copy of the built-in field list — the drift `seed-form-invariants.test.ts` exists + * to catch. Assert they keep reaching for the helpers instead of spelling the fields out. + */ + it('builds the sized sibling CFP from the shared form helpers', () => { + const sizedDemo = source('./seeds/sized-demo.ts'); + expect(sizedDemo).toContain('seedBuiltinFields(cfp.id)'); + expect(sizedDemo).toContain('seedRoles(cfp.id)'); + expect(sizedDemo).not.toMatch(/builtinKey: '/); + }); + it('keeps the public bundle gated to confirmed participants', () => { const publicQueries = source('../app/embed/queries.ts'); expect(publicQueries).toContain("eq(participant.workflowStatus, 'confirmed')"); diff --git a/db/seed.ts b/db/seed.ts index 36dbeb64..eda6c509 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -1,4 +1,4 @@ -import { eq, inArray } from 'drizzle-orm'; +import { eq, inArray, like, or } from 'drizzle-orm'; import { requireEventWindow } from '../lib/event-dates'; import { newIcsUid } from '../lib/ics'; import { ensureDefaultTemplates } from '../lib/services/comms'; @@ -9,9 +9,12 @@ import { import { splitPersonName } from '../lib/person-name'; import type { RomanSpeakerHeadshotGender } from '../lib/roman-speaker-headshots'; import { getDb } from './client'; +import { EVENT_SIZES, SIBLING_EVENT_SIZES, generatedEmailDomain } from './seeds/event-sizes'; import { seedFirstSettlement } from './seeds/first-settlement'; import { removeEventFiles, seedProfileArt } from './seeds/profile-art-store'; import { ROMAN_PROFILE_ART } from './seeds/roman-profile-art'; +import { seedSizedDemo } from './seeds/sized-demo'; +import { seedSizedRoster } from './seeds/sized-roster'; import { emailLog, event, @@ -157,6 +160,25 @@ await db.delete(user).where( ), ); +/** + * The generated crowd is swept by domain rather than by a list, because the list is a function of + * the size profiles and those change. Shrinking `large` from 180 speakers to 120 with a name-by-name + * delete would leave sixty accounts behind that no event references and no later run ever collects — + * and the next run's insert would collide with them on `email`. + * + * Safe after the event sweep above and not before: these accounts own nothing, but their + * submissions and participant rows only disappear when the events they belong to do. + */ +await db + .delete(user) + .where( + or( + ...[EVENT_SIZES.medium, ...SIBLING_EVENT_SIZES].map((size) => + like(user.email, `%@${generatedEmailDomain(size)}`), + ), + ), + ); + // --------------------------------------------------------------------------- // People and the event // --------------------------------------------------------------------------- @@ -254,6 +276,10 @@ const rooms = await db { eventId: demo.id, name: 'Outer Peristyle', capacity: 600, floor: 'Ground', position: 0 }, { eventId: demo.id, name: 'Basilica Gallery', capacity: 180, floor: 'Ground', position: 1 }, { eventId: demo.id, name: 'Villa Workshop', capacity: 60, floor: 'Lower level', position: 2 }, + // The two rooms the generated programme runs in. The hand-written placements below stay in the + // three above, which is what keeps the two halves of the agenda from ever colliding. + { eventId: demo.id, name: 'East Garden Room', capacity: 120, floor: 'Ground', position: 3 }, + { eventId: demo.id, name: 'Atrium Studio', capacity: 90, floor: 'Lower level', position: 4 }, ]) .returning(); @@ -1503,13 +1529,60 @@ await db.insert(emailLog).values([ }, ]); +// --------------------------------------------------------------------------- +// Scale. Everything above is hand-written and is what a reader meets first; this brings the same +// event up to the medium size profile, which is what makes the review queue, the agenda grid and +// the speaker gallery worth looking at. `demo` stays the default sample event — the small and +// large siblings below exist to be compared against it, not to replace it. +// --------------------------------------------------------------------------- + +const filler = await seedSizedRoster(db, { + eventId: demo.id, + size: EVENT_SIZES.medium, + organizerUserId: organizer.id, + formId: cfp.id, + timezone: TIMEZONE, + tracks, + formats, + rooms, + personas, + days: [at(day1, 0), at(day2, 0)], + now, + existing: { + speakers: SPEAKER_EMAILS.length, + submissions: submissions.length, + sessions: scheduled.length, + }, + // The three rooms the hand-written placements above use. + reservedRooms: 3, + review: { + roundId: rounds[0].id, + reviewerUserIds: reviewers.map((reviewer) => reviewer.id), + criteria: criteriaByRound.get(rounds[0].id)!, + }, +}); + +const siblings = []; +for (const size of SIBLING_EVENT_SIZES) { + siblings.push(await seedSizedDemo(db, { size, organizerUserId: organizer.id, now })); +} + const firstSettlement = await seedFirstSettlement(db, organizer.id, now); console.log( - `Seeded /${SLUG}: ${submissions.length} submissions, ${uniqueAccepted.length} speakers, ` + - `${scheduled.length} scheduled sessions, ${tasks.length + scopedTasks.length} tasks. ` + + `Seeded /${SLUG} (${EVENT_SIZES.medium.key}): ` + + `${submissions.length + filler.submissions} submissions, ` + + `${uniqueAccepted.length + filler.speakers} speakers, ` + + `${scheduled.length + filler.scheduledSessions} scheduled sessions, ` + + `${tasks.length + scopedTasks.length} tasks. ` + `Sign in as ${organizer.email} and read the link at /organizer/mail.`, ); +for (const sibling of siblings) { + console.log( + `Seeded /${sibling.slug} (${sibling.size}): ${sibling.submissions} submissions, ` + + `${sibling.speakers} speakers, ${sibling.scheduledSessions} scheduled sessions.`, + ); +} console.log( `Seeded /${firstSettlement.slug}: ${firstSettlement.submissions} submissions, ` + `${firstSettlement.speakers} speakers, ${firstSettlement.scheduledSessions} scheduled sessions, ` + diff --git a/db/seeds/event-sizes.test.ts b/db/seeds/event-sizes.test.ts new file mode 100644 index 00000000..28d120aa --- /dev/null +++ b/db/seeds/event-sizes.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { ROMAN_SPEAKER_HEADSHOT_CAPACITY } from '../../lib/roman-speaker-headshots'; +import { + ALL_EVENT_SIZES, + DEFAULT_EVENT_SIZE, + EVENT_SIZES, + SIZED_EVENT_SLUGS, + generatedEmailDomain, +} from './event-sizes'; +import { ROMAN_PROFILE_ART } from './roman-profile-art'; + +/** + * These are the assertions that keep the three sizes from quietly interfering with each other. Each + * one has a failure mode that is invisible on the seeded database and only shows up on a screen: + * overlapping portrait ranges look like a bug in the generator, and an event whose accepted talks + * outnumber its slots throws in the middle of a seed run that has already written half an event. + */ + +describe('event size profiles', () => { + it('keeps the default sample event on the `demo` slug', () => { + expect(EVENT_SIZES[DEFAULT_EVENT_SIZE].slug).toBe('demo'); + expect(SIZED_EVENT_SLUGS[0]).toBe('demo'); + }); + + it('grows monotonically, so the three are actually comparable', () => { + const speakers = ALL_EVENT_SIZES.map((size) => size.speakers); + const submissions = ALL_EVENT_SIZES.map((size) => size.submissions); + expect(speakers).toEqual([...speakers].sort((a, b) => a - b)); + expect(submissions).toEqual([...submissions].sort((a, b) => a - b)); + }); + + it('asks for more proposals than it accepts, or the review queue is empty', () => { + for (const size of ALL_EVENT_SIZES) { + expect(size.submissions).toBeGreaterThan(size.speakers); + } + }); + + /** + * `first-settlement` holds slots 0 to 12. Two events sharing a slot hand two different people the + * same face, which reads as a broken generator rather than as a seed collision. + */ + it('draws non-overlapping portrait ranges that fit the generator', () => { + const ranges = [ + { key: 'first-settlement', from: 0, to: ROMAN_PROFILE_ART.length - 1 }, + ...ALL_EVENT_SIZES.map((size) => ({ + key: size.key, + from: size.headshotSlotOffset, + to: size.headshotSlotOffset + size.speakers - 1, + })), + ].sort((a, b) => a.from - b.from); + + for (const range of ranges) { + expect(range.to).toBeLessThan(ROMAN_SPEAKER_HEADSHOT_CAPACITY); + } + for (const [index, range] of ranges.slice(1).entries()) { + const previous = ranges[index]!; + expect({ after: previous.key, key: range.key, clear: range.from > previous.to }).toEqual({ + after: previous.key, + key: range.key, + clear: true, + }); + } + }); + + /** + * `sized-roster.ts` schedules into 14 half-hour slots a day. `demo` reserves its first three rooms + * for the hand-written agenda, so only the rooms past those count towards its capacity. + */ + it('has room on the grid for every accepted talk', () => { + const SLOTS_PER_DAY = 14; + const reserved: Record = { medium: 3 }; + for (const size of ALL_EVENT_SIZES) { + const openRooms = size.rooms - (reserved[size.key] ?? 0); + expect({ key: size.key, fits: openRooms * size.days * SLOTS_PER_DAY >= size.speakers }).toEqual( + { key: size.key, fits: true }, + ); + } + }); + + it('puts generated identities on a domain nothing can be delivered to', () => { + for (const size of ALL_EVENT_SIZES) { + expect(generatedEmailDomain(size).endsWith('.example')).toBe(true); + } + }); + + it('gives every size a distinct slug', () => { + expect(new Set(SIZED_EVENT_SLUGS).size).toBe(SIZED_EVENT_SLUGS.length); + }); +}); diff --git a/db/seeds/event-sizes.ts b/db/seeds/event-sizes.ts new file mode 100644 index 00000000..95c59f77 --- /dev/null +++ b/db/seeds/event-sizes.ts @@ -0,0 +1,106 @@ +/** + * How big the sample conferences are. + * + * A seven-speaker demo is honest about the product's *shape* and dishonest about its *load*. Every + * screen that has to cope with a real call for papers — the review queue, the agenda grid, the + * speaker gallery, the assignment spread across a reviewer pool — looks effortless on fourteen + * proposals and is the entire job on four hundred. So the seed ships one event at each of three + * scales instead of one event at whichever scale was convenient, and the middle one is what a + * visitor lands on. + * + * `demo` is the medium event and keeps its slug: it is the default sample event, it is what + * `lib/demo-entry-links.ts` points at, and it is the one with the hand-authored narrative core. + * `demo-small` and `demo-large` are the same conference wound down and up, so a reader comparing + * them is looking at scale rather than at two unrelated fixtures. + * + * ## Headshot slots + * + * Portraits come from the deterministic generator in `roman-profile-art.ts`, which has + * `ROMAN_SPEAKER_HEADSHOT_CAPACITY` (600) distinct slots. Each event draws a contiguous range, and + * the ranges must not overlap or two speakers in different events end up with the same face — which + * looks like a bug in the generator and is not one. The offsets below are spaced with room to grow: + * `first-settlement` holds 0-12 (the thirteen hand-authored `ROMAN_PROFILE_ART` entries), and the + * assertions in `event-sizes.test.ts` fail if a future edit lets two ranges touch. + */ + +export type EventSizeKey = 'small' | 'medium' | 'large'; + +export type EventSize = { + key: EventSizeKey; + slug: string; + name: string; + tagline: string; + /** Confirmed speakers on the published roster — one generated person per speaker. */ + speakers: number; + /** Proposals in the call, across every status. Roughly two per speaker, as real calls run. */ + submissions: number; + /** Concurrent rooms. Enough that the accepted talks fit the grid without double-booking. */ + rooms: number; + days: number; + /** Reviewers on the programme committee, sized so nobody carries an absurd queue. */ + reviewers: number; + /** First `roman-profile-art` slot this event draws from. Ranges must not overlap. */ + headshotSlotOffset: number; +}; + +export const EVENT_SIZES: Record = { + small: { + key: 'small', + slug: 'demo-small', + name: 'Cicero Forum: Provincial Assembly', + tagline: 'A single-track day for one province, sized like a community conference.', + speakers: 8, + submissions: 18, + rooms: 2, + days: 1, + reviewers: 2, + headshotSlotOffset: 100, + }, + medium: { + key: 'medium', + slug: 'demo', + name: 'Cicero Forum 2026', + tagline: 'The default sample event: a two-day, multi-track conference.', + speakers: 45, + submissions: 96, + rooms: 5, + days: 2, + reviewers: 6, + headshotSlotOffset: 13, + }, + large: { + key: 'large', + slug: 'demo-large', + name: 'Cicero Forum: Imperial Congress', + tagline: 'Three days, ten rooms, and a call for papers big enough to hurt.', + speakers: 180, + submissions: 384, + rooms: 10, + days: 3, + reviewers: 18, + headshotSlotOffset: 200, + }, +}; + +/** The size a visitor lands on when nobody has asked for one. */ +export const DEFAULT_EVENT_SIZE: EventSizeKey = 'medium'; + +/** The two events seeded beside the default, in the order they are created. */ +export const SIBLING_EVENT_SIZES: EventSize[] = [EVENT_SIZES.small, EVENT_SIZES.large]; + +export const ALL_EVENT_SIZES: EventSize[] = [ + EVENT_SIZES.small, + EVENT_SIZES.medium, + EVENT_SIZES.large, +]; + +/** Every sample-event slug the seed creates, default first. */ +export const SIZED_EVENT_SLUGS: string[] = [ + EVENT_SIZES[DEFAULT_EVENT_SIZE].slug, + ...SIBLING_EVENT_SIZES.map((size) => size.slug), +]; + +/** The mail domain a generated speaker for this event gets. Reserved by RFC 2606, so undeliverable. */ +export function generatedEmailDomain(size: EventSize): string { + return `${size.slug}.example`; +} diff --git a/db/seeds/generated-roster.test.ts b/db/seeds/generated-roster.test.ts new file mode 100644 index 00000000..c6e34837 --- /dev/null +++ b/db/seeds/generated-roster.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { undeliverableRecipient } from '../../lib/mail/config'; +import { ALL_EVENT_SIZES, EVENT_SIZES, generatedEmailDomain } from './event-sizes'; +import { generateProposals, generateSpeakers, generatedSpeakerAt } from './generated-roster'; + +/** + * The generator's whole job is to be boring in exactly two ways: the same index always gives the + * same person, and no two indices give the same email. Both are load-bearing and neither is + * enforced by a type — a collision surfaces as a unique-constraint failure halfway through a seed + * run, and a drift surfaces as every screenshot in the docs going stale at once. + * + * The largest size is the one worth asserting at: it is where the name tables wrap. + */ + +const LARGE = EVENT_SIZES.large; +const DOMAIN = generatedEmailDomain(LARGE); + +describe('generated speakers', () => { + it('is a pure function of the index', () => { + for (const index of [0, 1, 7, 42, 179]) { + expect(generatedSpeakerAt(index, DOMAIN)).toEqual(generatedSpeakerAt(index, DOMAIN)); + } + }); + + it('gives every speaker at the largest size a distinct address', () => { + const emails = generateSpeakers(LARGE.speakers, { domain: DOMAIN }).map((s) => s.email); + expect(new Set(emails).size).toBe(LARGE.speakers); + }); + + /** The reviewer block in `sized-demo.ts` starts at 1000; it must not reach back into the roster. */ + it('keeps the reviewer index block clear of the speaker block', () => { + const speakers = generateSpeakers(LARGE.speakers, { domain: DOMAIN }); + const reviewers = Array.from({ length: LARGE.reviewers }, (_, i) => + generatedSpeakerAt(1000 + i, DOMAIN), + ); + const all = [...speakers, ...reviewers].map((person) => person.email); + expect(new Set(all).size).toBe(all.length); + }); + + it('gives distinct names, not just distinct addresses', () => { + const names = generateSpeakers(LARGE.speakers, { domain: DOMAIN }).map((s) => s.name); + expect(new Set(names).size).toBe(LARGE.speakers); + }); + + /** + * `lib/demo-access.ts` will only print an on-screen magic link for an address no mailbox can exist + * behind. Moving these people to a deliverable domain would turn that convenience into a way to + * take over an account, so the property is asserted rather than left to the domain constant. + */ + it('puts every generated person at a reserved domain', () => { + for (const size of ALL_EVENT_SIZES) { + const speakers = generateSpeakers(size.speakers, { domain: generatedEmailDomain(size) }); + expect(speakers.every((speaker) => undeliverableRecipient(speaker.email))).toBe(true); + } + }); + + it('stays close to an even gender split at every size', () => { + for (const size of ALL_EVENT_SIZES) { + const speakers = generateSpeakers(size.speakers, { domain: generatedEmailDomain(size) }); + const women = speakers.filter((speaker) => speaker.gender === 'woman').length; + expect(Math.abs(women - speakers.length / 2)).toBeLessThanOrEqual(1); + } + }); + + it('fills in the profile fields the public roster renders', () => { + for (const speaker of generateSpeakers(24, { domain: DOMAIN })) { + expect(speaker.title.length).toBeGreaterThan(0); + expect(speaker.organization.length).toBeGreaterThan(0); + expect(speaker.bio.length).toBeGreaterThan(40); + } + }); + + /** `startIndex` is how `demo` continues past its seven hand-authored speakers. */ + it('offsets cleanly from a start index', () => { + const offset = generateSpeakers(5, { domain: DOMAIN, startIndex: 7 }); + const full = generateSpeakers(12, { domain: DOMAIN }); + expect(offset).toEqual(full.slice(7)); + }); +}); + +describe('generated proposals', () => { + const speakers = generateSpeakers(LARGE.speakers, { domain: DOMAIN }); + const proposals = generateProposals(speakers, { + count: LARGE.submissions, + acceptedCount: LARGE.speakers, + }); + + it('accepts exactly one proposal per speaker', () => { + const accepted = proposals.filter((proposal) => proposal.status === 'accepted'); + expect(accepted).toHaveLength(LARGE.speakers); + expect(new Set(accepted.map((proposal) => proposal.email)).size).toBe(LARGE.speakers); + }); + + /** + * A programme with the same title twice makes the agenda look like a rendering bug. Topics and + * framings are paired by coprime stride precisely so this holds up to 480 proposals. + */ + it('gives every proposal at the largest size a distinct title', () => { + expect(new Set(proposals.map((proposal) => proposal.title)).size).toBe(LARGE.submissions); + }); + + it('leaves a real queue behind the accepted set', () => { + const statuses = new Set(proposals.map((proposal) => proposal.status)); + expect(statuses.has('under_review')).toBe(true); + expect(statuses.has('declined')).toBe(true); + expect(statuses.has('waitlisted')).toBe(true); + }); + + it('only ever names a speaker that exists', () => { + const known = new Set(speakers.map((speaker) => speaker.email)); + expect(proposals.every((proposal) => known.has(proposal.email))).toBe(true); + }); + + it('is a pure function of its inputs', () => { + expect( + generateProposals(speakers, { count: 20, acceptedCount: 8, titleOffset: 14 }), + ).toEqual(generateProposals(speakers, { count: 20, acceptedCount: 8, titleOffset: 14 })); + }); + + /** `demo` starts its generated proposals after the fourteen hand-written ones. */ + it('does not repeat a hand-authored slot when offset', () => { + const offsetTitles = generateProposals(speakers, { + count: 30, + acceptedCount: 10, + titleOffset: 14, + }).map((proposal) => proposal.title); + const baseTitles = generateProposals(speakers, { count: 14, acceptedCount: 7 }).map( + (proposal) => proposal.title, + ); + expect(offsetTitles.filter((title) => baseTitles.includes(title))).toEqual([]); + }); +}); diff --git a/db/seeds/generated-roster.ts b/db/seeds/generated-roster.ts new file mode 100644 index 00000000..2ce4f434 --- /dev/null +++ b/db/seeds/generated-roster.ts @@ -0,0 +1,301 @@ +/** + * The people and proposals that make a sample event big. + * + * Nobody is going to hand-write four hundred abstracts, and a sample event that only has fourteen + * of them cannot show what the product is for: a review queue, an agenda grid and an assignment + * spread only become interesting under load. So the bulk of a sized event is generated here, while + * the hand-authored core in `seed.ts` stays exactly as it is — a reader still meets Vitruvius and + * Cornelia first, and the crowd behind them is filler that reads like a conference rather than like + * `Speaker 214`. + * + * ## Everything is a pure function of an index + * + * There is no randomness. Slot `i` always produces the same person, the same email and the same + * portrait, which is what makes a reseed non-destructive in the ways that matter: the same speaker + * keeps their face and their sign-in address, a screenshot taken last week still matches, and the + * invariant tests can assert on concrete output instead of on a shape. `Math.random()` here would + * cost all of that and buy nothing — the names are already varied enough at the sizes we seed. + * + * Names are drawn by mixed-radix decomposition of the index rather than by hashing, so uniqueness + * is a property of the arithmetic and not a hope: distinct indices give distinct + * `(nomen, cognomen)` pairs for the first `NOMINA.length * COGNOMINA.length` people of each gender, + * which is an order of magnitude more than the largest event seeds. `generated-roster.test.ts` + * asserts it at the sizes actually used. + * + * ## Addresses + * + * Generated speakers live on `.example`. That is not decoration: `lib/demo-access.ts` + * will only ever print an on-screen magic link for an address at an RFC 2606 / 6761 reserved + * domain, because no mailbox can exist behind one. Moving these people to a domain that could + * receive mail would quietly turn a demo convenience into an account-takeover path, so the domain + * is derived in `event-sizes.ts` and never spelled by hand here. + */ + +export type SpeakerGender = 'man' | 'woman'; + +export type GeneratedSpeaker = { + email: string; + name: string; + gender: SpeakerGender; + title: string; + organization: string; + bio: string; + pronouns?: string; +}; + +export type GeneratedProposal = { + email: string; + title: string; + abstract: string; + takeaways: string; + level: string; + status: 'submitted' | 'under_review' | 'accepted' | 'declined' | 'waitlisted'; + /** Index into the event's track list, modulo its length. */ + trackIndex: number; + /** Index into the event's format list, modulo its length. */ + formatIndex: number; + daysAgo: number; +}; + +const PRAENOMINA = [ + 'Gaius', 'Lucius', 'Marcus', 'Publius', 'Quintus', 'Titus', 'Tiberius', 'Sextus', 'Aulus', + 'Decimus', 'Gnaeus', 'Spurius', 'Manius', 'Servius', 'Appius', 'Numerius', 'Vibius', +] as const; + +/** All end in `-ius`, so the feminine form is a rule rather than a second table. */ +const NOMINA = [ + 'Aemilius', 'Antonius', 'Aurelius', 'Caecilius', 'Calpurnius', 'Cassius', 'Claudius', + 'Cornelius', 'Curtius', 'Decius', 'Domitius', 'Duilius', 'Fabius', 'Flavius', 'Fulvius', + 'Furius', 'Gellius', 'Horatius', 'Hostilius', 'Julius', 'Junius', 'Licinius', 'Livius', + 'Lucretius', 'Manlius', 'Marcius', 'Memmius', 'Minucius', 'Mucius', 'Naevius', 'Octavius', + 'Papirius', 'Pompeius', 'Postumius', 'Quinctius', 'Rutilius', 'Sempronius', 'Servilius', + 'Sulpicius', 'Terentius', 'Valerius', 'Veturius', 'Vipsanius', +] as const; + +const MALE_COGNOMINA = [ + 'Agricola', 'Ahenobarbus', 'Balbus', 'Brutus', 'Caepio', 'Celsus', 'Cinna', 'Cotta', 'Crassus', + 'Crispus', 'Dentatus', 'Drusus', 'Faustus', 'Flaccus', 'Galba', 'Gracchus', 'Longinus', 'Lupus', + 'Macer', 'Magnus', 'Marcellus', 'Maximus', 'Nerva', 'Niger', 'Paullus', 'Pictor', 'Piso', + 'Priscus', 'Pulcher', 'Regulus', 'Rufus', 'Sabinus', 'Scaevola', 'Scipio', 'Severus', 'Silanus', + 'Strabo', 'Tubero', 'Varus', 'Vespillo', +] as const; + +/** A separate table rather than a derived form: mechanical `-us` to `-a` produces bad Latin. */ +const FEMALE_COGNOMINA = [ + 'Agrippina', 'Balbina', 'Celsa', 'Crispina', 'Domitilla', 'Drusilla', 'Fausta', 'Flaccilla', + 'Galeria', 'Gratiana', 'Hostilia', 'Justina', 'Lepida', 'Longina', 'Lucilla', 'Marcella', + 'Marciana', 'Matidia', 'Maxima', 'Messalina', 'Nigrina', 'Paulina', 'Plancina', 'Plotina', + 'Prisca', 'Procula', 'Pulchra', 'Quarta', 'Regilla', 'Rufina', 'Sabina', 'Secunda', 'Serena', + 'Severa', 'Silana', 'Tertia', 'Tranquilla', 'Valeriana', 'Verula', 'Vibiana', +] as const; + +const TITLES = [ + 'Aqueduct engineer', 'Public works surveyor', 'Grain supply administrator', 'Legal advocate', + 'Archive keeper', 'Rhetoric instructor', 'Mint superintendent', 'Harbour master', 'Census clerk', + 'Military engineer', 'Road commissioner', 'Treasury auditor', 'Provincial administrator', + 'Medical practitioner', 'Cartographer', 'Master shipwright', +] as const; + +const ORGANIZATIONS = [ + 'Office of Public Works', 'Ostia Harbour Authority', 'Grain Board', 'Public Libraries', + 'Rhetoric School of Rhodes', 'Provincial Assembly of Baetica', 'Colonia Narbo Martius', + 'Aqueduct Commission', 'Treasury of Saturn', 'Legion Engineering Corps', 'Guild of Shipwrights', + 'Census Office', 'Via Appia Commission', 'Library of Alexandria', +] as const; + +const BIO_FOCUS = [ + 'measurement, tolerance, and what happens when neither is written down', + 'the last mile of a supply chain, where most of the losses actually are', + 'why maintenance is the first budget cut and the most expensive one', + 'making a public record legible to the people it describes', + 'the gap between what a plan says and what the site allows', + 'training replacements before they are urgently needed', + 'work that only gets noticed when it stops', + 'costing a decision honestly before anyone is committed to it', + 'the difference between a rule and a rule that is followed', + 'scheduling around weather, festivals, and the people who ignore both', + 'keeping a service running while it is being rebuilt', + 'inspection access as a design constraint rather than an afterthought', +] as const; + +const BIO_CLOSERS = [ + 'Prefers a small change that survives to a large one that does not.', + 'Keeps notes obsessively and shares them.', + 'Has opinions about drainage and will share them unprompted.', + 'Believes most disasters were legible in the paperwork first.', + 'Would rather be corrected early than right late.', + 'Learned all of this the expensive way.', + 'Argues for boring solutions in public and in writing.', + 'Thinks the interesting part of any project is the handover.', +] as const; + +const TOPICS = [ + 'aqueduct maintenance', 'grain convoy scheduling', 'road survey tolerances', 'harbour dredging', + 'census data quality', 'court calendar backlogs', 'apprenticeship pipelines', + 'concrete curing in winter', 'provincial tax appeals', 'archive indexing', 'signal relay latency', + 'bridge load testing', 'quarry logistics', 'water quality testing', 'public bath heating', + 'fire brigade response times', 'granary pest control', 'coin die wear', + 'legionary field hospitals', 'ferry timetables', 'boundary dispute mediation', + 'sewer inspection access', 'timber seasoning', 'olive press throughput', 'letter courier routing', + 'mosaic workshop scheduling', 'amphitheatre crowd flow', 'lighthouse keeping rotas', + 'well drilling records', 'milestone placement', 'ration accounting', 'shipwreck salvage', + 'contract arbitration', 'stone transport barges', 'aqueduct settling tanks', + 'street lighting trials', 'market weights and measures', 'garrison supply forecasting', + 'drainage in reclaimed marsh', 'public notice boards', +] as const; + +/** Each shape takes the topic verbatim, so no shape may need it capitalised mid-sentence. */ +const TITLE_SHAPES = [ + (topic: string) => `Lessons from ten years of ${topic}`, + (topic: string) => `What we got wrong about ${topic}`, + (topic: string) => `A practical guide to ${topic}`, + (topic: string) => `Scaling ${topic} beyond one city`, + (topic: string) => `Measuring ${topic} without guesswork`, + (topic: string) => `The hidden cost of ${topic}`, + (topic: string) => `Rebuilding ${topic} after a failure`, + (topic: string) => `Who actually owns ${topic}`, + (topic: string) => `Automating the tedious parts of ${topic}`, + (topic: string) => `When ${topic} meets a shrinking budget`, + (topic: string) => `Teaching ${topic} to people who just arrived`, + (topic: string) => `The case against how we do ${topic}`, +] as const; + +const ABSTRACT_OPENERS = [ + 'A field report on what actually changed in', + 'Hard numbers, and some honest failures, from', + 'A working method for', + 'Three years of measurements on', + 'What the records show about', + 'An argument, with evidence, about', + 'A postmortem on', + 'The cheapest reliable approach we have found to', +] as const; + +const ABSTRACT_CLOSERS = [ + 'Expect specifics rather than principles.', + 'You should leave with a checklist you can use next week.', + 'Includes the numbers, the sources, and the parts that did not work.', + 'Aimed at people who have to make this decision with an incomplete budget.', + 'Assumes you have done this before and are tired of the usual advice.', + 'Every claim here is one you can check against the public record.', + 'The method is boring on purpose; that is the point.', + 'Bring your own constraints and we will work through them.', +] as const; + +const TAKEAWAY_LINES = [ + 'Write the tolerance down before anyone starts', + 'Budget for inspection, not just for construction', + 'Measure the thing you actually care about', + 'Plan the handover on the first day', + 'The cheap option is cheap until the second failure', + 'Publish the numbers even when they are bad', + 'Design for the person who maintains it', + 'Agree who decides before you need a decision', + 'Small reversible changes beat large irreversible ones', + 'Most surprises were visible in the paperwork', + 'Train two people, not one', + 'Test under the load you will actually see', +] as const; + +const LEVELS = ['Beginner', 'Intermediate', 'Advanced'] as const; + +const NON_ACCEPTED: GeneratedProposal['status'][] = [ + 'under_review', 'submitted', 'declined', 'waitlisted', 'under_review', 'declined', +]; + +function feminineNomen(nomen: string): string { + return nomen.replace(/us$/, 'a'); +} + +function pick(pool: readonly T[], index: number): T { + return pool[index % pool.length]!; +} + +/** + * The person at slot `index`. Genders alternate so a roster of any length stays balanced, and each + * gender walks its own name tables independently — which is why the two never collide on an email. + */ +export function generatedSpeakerAt(index: number, domain: string): GeneratedSpeaker { + const gender: SpeakerGender = index % 2 === 0 ? 'woman' : 'man'; + const within = Math.floor(index / 2); + + const nomenIndex = within % NOMINA.length; + const carry = Math.floor(within / NOMINA.length); + const nomen = NOMINA[nomenIndex]!; + + const cognomina = gender === 'woman' ? FEMALE_COGNOMINA : MALE_COGNOMINA; + const cognomen = cognomina[(carry + nomenIndex) % cognomina.length]!; + + const family = gender === 'woman' ? feminineNomen(nomen) : nomen; + const name = + gender === 'woman' + ? `${family} ${cognomen}` + : `${pick(PRAENOMINA, within + carry)} ${family} ${cognomen}`; + + const title = pick(TITLES, index * 5 + 1); + const organization = pick(ORGANIZATIONS, index * 3 + 2); + + return { + email: `${cognomen}.${family}@${domain}`.toLowerCase(), + name, + gender, + title, + organization, + bio: `${title} at ${organization}, working on ${pick(BIO_FOCUS, index * 7)}. ${pick(BIO_CLOSERS, index * 3)}`, + ...(gender === 'woman' ? { pronouns: 'she/her' } : {}), + }; +} + +/** `count` distinct people on `domain`, starting from slot `startIndex`. */ +export function generateSpeakers( + count: number, + options: { domain: string; startIndex?: number }, +): GeneratedSpeaker[] { + const start = options.startIndex ?? 0; + return Array.from({ length: count }, (_, offset) => + generatedSpeakerAt(start + offset, options.domain), + ); +} + +/** + * One proposal per slot. The first `acceptedCount` are accepted and belong to distinct speakers in + * roster order, so every confirmed speaker has exactly one talk to be scheduled into; the rest are + * the queue that makes a review screen worth looking at, spread back over the same people. + */ +export function generateProposals( + speakers: readonly GeneratedSpeaker[], + options: { count: number; acceptedCount: number; titleOffset?: number }, +): GeneratedProposal[] { + const titleOffset = options.titleOffset ?? 0; + + return Array.from({ length: options.count }, (_, index) => { + const accepted = index < options.acceptedCount; + const speaker = speakers[index % speakers.length]!; + const slot = titleOffset + index; + + // Each pass through the topic table advances the shape by five, and five is coprime with the + // twelve shapes — so a topic never repeats with the same framing until every pairing is used. + const topicIndex = slot % TOPICS.length; + const topic = TOPICS[topicIndex]!; + const shape = + TITLE_SHAPES[ + (Math.floor(slot / TOPICS.length) * 5 + topicIndex) % TITLE_SHAPES.length + ]!; + + return { + email: speaker.email, + title: shape(topic), + abstract: `${pick(ABSTRACT_OPENERS, slot * 3)} ${topic}. ${pick(ABSTRACT_CLOSERS, slot * 5 + 1)}`, + takeaways: [ + pick(TAKEAWAY_LINES, slot), + pick(TAKEAWAY_LINES, slot * 2 + 1), + pick(TAKEAWAY_LINES, slot * 3 + 5), + ].join('\n'), + level: pick(LEVELS, slot + 1), + status: accepted ? 'accepted' : pick(NON_ACCEPTED, index), + trackIndex: slot, + formatIndex: slot * 2 + (accepted ? 0 : 1), + daysAgo: 34 - (slot % 30), + }; + }); +} diff --git a/db/seeds/sized-demo.ts b/db/seeds/sized-demo.ts new file mode 100644 index 00000000..3f1a370d --- /dev/null +++ b/db/seeds/sized-demo.ts @@ -0,0 +1,286 @@ +import { requireEventWindow } from '../../lib/event-dates'; +import { splitPersonName } from '../../lib/person-name'; +import { seedBuiltinFields, seedRoles } from '../../lib/services/forms'; +import type { Database } from '../client'; +import { + event, + form, + formField, + formParticipantRole, + membership, + persona, + reviewRound, + room, + scorecardCriterion, + sessionFormat, + track, + user, +} from '../schema'; +import type { EventSize } from './event-sizes'; +import { generatedEmailDomain } from './event-sizes'; +import { generatedSpeakerAt } from './generated-roster'; +import { seedSizedRoster } from './sized-roster'; + +/** + * The small and large sample events. + * + * `demo` is the one with the hand-authored narrative, and it stays the default. These two exist so + * that "does this screen still work at four hundred proposals" is a question you can answer by + * opening a URL rather than by writing a load fixture — and so that the honest answer to "how does + * it feel for a twenty-person meetup" is also one click away. Same conference, three scales. + * + * ## Why the form is not written out by hand + * + * Both existing seeds spell their CFP fields inline, which is how one of them came to be missing a + * built-in and hard-failed the first time an organizer pressed Publish — see the note on + * `db/seed-form-invariants.test.ts`. This one calls `seedBuiltinFields` and `seedRoles`, the same + * helpers `createForm` uses, so the invariant holds by construction instead of by a regex watching + * the source. A third hand-written copy of that list is exactly the drift those tests exist to + * catch. + */ + +const DAY = 86_400_000; + +const TRACK_NAMES = [ + { name: 'Infrastructure', color: 'lapis' }, + { name: 'Governance', color: 'vermilion' }, + { name: 'Knowledge & Communication', color: 'verdigris' }, + { name: 'Logistics & Operations', color: 'ochre' }, +] as const; + +const ROOM_NAMES = [ + 'Forum Hall', 'Basilica Gallery', 'East Garden Room', 'Atrium Studio', 'Curia Annexe', + 'Portico Room', 'Tabularium', 'Aqueduct Room', 'Lower Cloister', 'Marble Court', +] as const; + +export type SizedDemoResult = { + slug: string; + size: EventSize['key']; + speakers: number; + submissions: number; + scheduledSessions: number; +}; + +export async function seedSizedDemo( + db: Database, + params: { size: EventSize; organizerUserId: string; now: Date }, +): Promise { + const { size, organizerUserId, now } = params; + const timezone = 'America/Los_Angeles'; + const domain = generatedEmailDomain(size); + + // Deliberately not six weeks out like `demo`: three sample events landing on the same dates would + // make every "what is coming up" surface look like one conference triple-booked with itself. + const firstDay = new Date(now.getTime() + (size.key === 'small' ? 21 : 70) * DAY); + firstDay.setUTCHours(0, 0, 0, 0); + const days = Array.from({ length: size.days }, (_, index) => new Date(firstDay.getTime() + index * DAY)); + const lastDay = days[days.length - 1]!; + + const isoDate = (date: Date) => date.toISOString().slice(0, 10); + /** Wall-clock in the event's UTC-8 zone, matching `db/seed.ts`. */ + const at = (day: Date, hour: number) => new Date(day.getTime() + (hour + 8) * 3_600_000); + const ago = (offset: number) => new Date(now.getTime() - offset * DAY); + + const window = requireEventWindow( + timezone, + `${isoDate(firstDay)}T09:00`, + `${isoDate(lastDay)}T17:00`, + ); + + const [created] = await db + .insert(event) + .values({ + slug: size.slug, + name: size.name, + tagline: size.tagline, + descriptionMarkdown: + `The ${size.key} sample event: ${size.speakers} speakers, ${size.submissions} proposals, ` + + `${size.rooms} rooms over ${size.days} day${size.days === 1 ? '' : 's'}. It exists beside ` + + 'the default `demo` event so the same screens can be read at a different scale. Everything ' + + 'here is generated and editable — break it freely.', + eventType: 'Conference', + timezone: window.timezone, + startsAt: window.startsAt, + endsAt: window.endsAt, + startsOn: window.startsOn, + endsOn: window.endsOn, + venueName: 'The Getty Villa', + venueAddress: '17985 Pacific Coast Highway, Pacific Palisades, CA', + ownerUserId: organizerUserId, + }) + .returning(); + + // Generated at a high index so no reviewer can collide with a speaker on the same domain: the + // name tables are walked by index, and these two ranges never meet. + const reviewerPeople = Array.from({ length: size.reviewers }, (_, index) => + generatedSpeakerAt(1000 + index, domain), + ); + const reviewerUsers = await db + .insert(user) + .values( + reviewerPeople.map((person) => ({ + email: person.email, + name: person.name, + ...splitPersonName(person.name), + })), + ) + .returning(); + + await db.insert(membership).values([ + { userId: organizerUserId, eventId: created.id, role: 'organizer' as const }, + ...reviewerUsers.map((reviewer) => ({ + userId: reviewer.id, + eventId: created.id, + role: 'reviewer' as const, + })), + ]); + + const tracks = await db + .insert(track) + .values( + TRACK_NAMES.slice(0, size.key === 'small' ? 2 : 4).map((entry, index) => ({ + eventId: created.id, + name: entry.name, + color: entry.color, + position: index, + })), + ) + .returning(); + + const rooms = await db + .insert(room) + .values( + Array.from({ length: size.rooms }, (_, index) => ({ + eventId: created.id, + name: ROOM_NAMES[index % ROOM_NAMES.length]!, + capacity: index === 0 ? 600 : 120, + floor: index < 2 ? 'Ground' : 'Lower level', + position: index, + })), + ) + .returning(); + + const formats = await db + .insert(sessionFormat) + .values([ + { eventId: created.id, name: 'Keynote', durationMinutes: 45, position: 0 }, + { eventId: created.id, name: 'Talk', durationMinutes: 30, position: 1 }, + { eventId: created.id, name: 'Workshop', durationMinutes: 90, position: 2 }, + ]) + .returning(); + + const personas = await db + .insert(persona) + .values([ + { + eventId: created.id, + name: 'Public works engineer', + description: 'Builds and maintains the city', + position: 0, + }, + { + eventId: created.id, + name: 'Civic leader', + description: 'Makes policy and coordinates institutions', + position: 1, + }, + ]) + .returning(); + + const [cfp] = await db + .insert(form) + .values({ + eventId: created.id, + kind: 'cfp', + targetType: 'abstract', + collectsParticipants: true, + name: `${size.name} — main call`, + externalTitle: `${size.name} call for speakers`, + pageHeading: 'Speak with us', + showWelcome: true, + slug: 'speak', + status: 'open', + maxParticipants: 4, + introMarkdown: + 'Practical talks rooted in Roman infrastructure, governance, knowledge, or logistics. ' + + 'Show the work rather than the legend.', + closesAt: new Date(firstDay.getTime() - 14 * DAY), + }) + .returning(); + + await db.insert(formField).values(seedBuiltinFields(cfp.id)); + await db.insert(formParticipantRole).values(seedRoles(cfp.id)); + + const [round] = await db + .insert(reviewRound) + .values({ + eventId: created.id, + name: 'First pass', + position: 0, + status: 'open', + blindUntilClose: true, + opensAt: ago(30), + closesAt: new Date(now.getTime() + 7 * DAY), + }) + .returning(); + + const criteria = await db + .insert(scorecardCriterion) + .values([ + { + reviewRoundId: round.id, + label: 'Relevance', + description: 'Does this matter to the audience we are convening?', + weight: 2, + maxScore: 5, + position: 0, + }, + { + reviewRoundId: round.id, + label: 'Depth', + description: 'Is there something here you cannot get from a blog post?', + weight: 2, + maxScore: 5, + position: 1, + }, + { + reviewRoundId: round.id, + label: 'Speaker readiness', + description: 'Evidence they can deliver it well.', + weight: 1, + maxScore: 5, + position: 2, + }, + ]) + .returning(); + + const filled = await seedSizedRoster(db, { + eventId: created.id, + size, + organizerUserId, + formId: cfp.id, + timezone, + tracks, + formats, + rooms, + personas, + days: days.map((day) => at(day, 0)), + now, + // Nothing is hand-written on these events, so the generator supplies the whole programme. + existing: { speakers: 0, submissions: 0, sessions: 0 }, + reservedRooms: 0, + review: { + roundId: round.id, + reviewerUserIds: reviewerUsers.map((reviewer) => reviewer.id), + criteria, + }, + }); + + return { + slug: size.slug, + size: size.key, + speakers: filled.speakers, + submissions: filled.submissions, + scheduledSessions: filled.scheduledSessions, + }; +} diff --git a/db/seeds/sized-roster.ts b/db/seeds/sized-roster.ts new file mode 100644 index 00000000..69ead492 --- /dev/null +++ b/db/seeds/sized-roster.ts @@ -0,0 +1,301 @@ +import { eq } from 'drizzle-orm'; +import { newIcsUid } from '../../lib/ics'; +import { splitPersonName } from '../../lib/person-name'; +import type { Database } from '../client'; +import { + event, + participant, + participantRole, + reviewAssignment, + scheduledSession, + score, + submission, + user, +} from '../schema'; +import type { EventSize } from './event-sizes'; +import { generatedEmailDomain } from './event-sizes'; +import { generateProposals, generateSpeakers } from './generated-roster'; +import { seedProfileArt } from './profile-art-store'; + +/** + * Fills a sample event out to its size. + * + * The hand-authored core of an event — Vitruvius, Cornelia, the fourteen proposals somebody + * actually wrote — is what makes the demo readable. This is what makes it *load-bearing*: the rest + * of the roster, the rest of the call, the rest of the grid. It is additive by design, so the + * narrative fixtures above it keep their refs, their reviews and their agenda placements exactly as + * they were, and a reader who only ever looks at the first screen sees no difference. + * + * Used by `db/seed.ts` to bring `demo` up to medium, and by `db/seeds/sized-demo.ts` to build the + * small and large siblings from an otherwise bare event. + * + * ## Why the generated sessions get their own rooms + * + * Placing them anywhere in the grid would mean checking each candidate slot against the + * hand-authored placements, which have deliberately awkward starts and a ninety-minute workshop. + * Reserving the first `reservedRooms` rooms for the hand-written agenda makes overlap impossible by + * construction instead of by a predicate that has to stay correct as the fixtures are edited — and + * an organizer looking at the board still sees one continuous programme. + */ + +/** 30-minute slots across a conference day, with an hour out for lunch. */ +const SLOT_MINUTES = 30; +const SLOTS_PER_DAY = [ + 9 * 60, 9 * 60 + 30, 10 * 60, 10 * 60 + 30, 11 * 60, 11 * 60 + 30, + 13 * 60, 13 * 60 + 30, 14 * 60, 14 * 60 + 30, 15 * 60, 15 * 60 + 30, + 16 * 60, 16 * 60 + 30, +]; + +export type SizedRosterResult = { + speakers: number; + submissions: number; + scheduledSessions: number; +}; + +export type SizedRosterParams = { + eventId: string; + size: EventSize; + organizerUserId: string; + formId: string; + timezone: string; + tracks: readonly { id: string }[]; + formats: readonly { id: string; durationMinutes: number; name: string }[]; + rooms: readonly { id: string }[]; + personas: readonly { id: string }[]; + /** + * One instant per conference day: local midnight, expressed in UTC. Slot times below are added + * as wall-clock minutes, so passing a bare UTC midnight for an event that is not on UTC would + * schedule the whole programme at the wrong hour. + */ + days: readonly Date[]; + now: Date; + /** Speakers, proposals and sessions the hand-authored fixtures already created. */ + existing: { speakers: number; submissions: number; sessions: number }; + /** Rooms the hand-authored agenda already occupies. Generated sessions never touch them. */ + reservedRooms: number; + /** Optional: put the generated queue in front of reviewers so the review screen has volume. */ + review?: { + roundId: string; + reviewerUserIds: readonly string[]; + criteria: readonly { id: string; maxScore: number }[]; + }; +}; + +/** Deterministic spread, so the sorted review queue means something. Mirrors `seed.ts`. */ +function scoreFor(seed: number, max: number): number { + return ((seed * 7919) % max) + 1; +} + +export async function seedSizedRoster( + db: Database, + params: SizedRosterParams, +): Promise { + const { size, existing } = params; + const speakerCount = size.speakers - existing.speakers; + const proposalCount = size.submissions - existing.submissions; + + if (speakerCount <= 0 || proposalCount <= 0) { + throw new Error( + `Size "${size.key}" is smaller than the fixtures already on ${size.slug}: ` + + `${size.speakers} speakers / ${size.submissions} proposals requested, ` + + `${existing.speakers} / ${existing.submissions} already seeded.`, + ); + } + + const openRooms = params.rooms.slice(params.reservedRooms); + const capacity = openRooms.length * params.days.length * SLOTS_PER_DAY.length; + if (capacity < speakerCount) { + throw new Error( + `Size "${size.key}" cannot be scheduled: ${speakerCount} accepted talks need more than the ` + + `${capacity} slots in ${openRooms.length} rooms over ${params.days.length} days.`, + ); + } + + const speakers = generateSpeakers(speakerCount, { + domain: generatedEmailDomain(size), + startIndex: existing.speakers, + }); + + // One accepted proposal per generated speaker, so every one of them has a talk on the grid and + // the public roster length is exactly what the size profile promises. + const proposals = generateProposals(speakers, { + count: proposalCount, + acceptedCount: speakerCount, + titleOffset: existing.submissions, + }); + + const speakerUsers = await db + .insert(user) + .values( + speakers.map((speaker) => ({ + email: speaker.email, + name: speaker.name, + ...splitPersonName(speaker.name), + })), + ) + .returning(); + const userByEmail = new Map(speakerUsers.map((row) => [row.email, row])); + + const profileArt = await seedProfileArt(db, { + eventId: params.eventId, + uploadedByUserId: params.organizerUserId, + speakerKeys: speakers.map((speaker) => speaker.email), + slotOffset: size.headshotSlotOffset + existing.speakers, + gender: (email) => speakers.find((speaker) => speaker.email === email)?.gender, + }); + + const generatedParticipants = await db + .insert(participant) + .values( + speakers.map((speaker) => ({ + eventId: params.eventId, + userId: userByEmail.get(speaker.email)!.id, + displayName: speaker.name, + pronouns: speaker.pronouns ?? null, + jobTitle: speaker.title, + company: speaker.organization, + bioMarkdown: speaker.bio, + // Same reason as the hand-authored roster: the schema default is `invited`, which the + // public read model excludes, and a gallery of invited speakers renders empty. + headshotFileId: profileArt.get(speaker.email)!, + timezone: params.timezone, + workflowStatus: 'confirmed' as const, + links: [{ label: 'Website', url: 'https://example.com' }], + })), + ) + .returning(); + const participantByUser = new Map(generatedParticipants.map((row) => [row.userId, row])); + + const ago = (days: number) => new Date(params.now.getTime() - days * 86_400_000); + const talkFormat = params.formats.find((format) => format.name === 'Talk') ?? params.formats[0]!; + + const generatedSubmissions = await db + .insert(submission) + .values( + proposals.map((proposal, index) => ({ + eventId: params.eventId, + formId: params.formId, + ref: existing.submissions + index + 1, + submitterUserId: userByEmail.get(proposal.email)!.id, + title: proposal.title, + descriptionMarkdown: proposal.abstract, + // Accepted talks all run 30 minutes so they tile the grid without overlapping. + formatId: + proposal.status === 'accepted' + ? talkFormat.id + : params.formats[proposal.formatIndex % params.formats.length]!.id, + trackId: params.tracks[proposal.trackIndex % params.tracks.length]!.id, + level: proposal.level, + personaId: params.personas[index % params.personas.length]!.id, + status: proposal.status, + answers: { takeaways: proposal.takeaways, given_before: false }, + submittedAt: ago(proposal.daysAgo), + decidedAt: ['accepted', 'declined', 'waitlisted'].includes(proposal.status) ? ago(4) : null, + decisionNote: + proposal.status === 'declined' + ? 'A good proposal that lost to a stronger one in the same track.' + : null, + createdAt: ago(proposal.daysAgo), + })), + ) + .returning(); + + await db.insert(participantRole).values( + generatedSubmissions.map((row) => ({ + submissionId: row.id, + participantId: participantByUser.get(row.submitterUserId)!.id, + kind: 'speaker' as const, + isPrimary: true, + })), + ); + + // --------------------------------------------------------------------------- + // Agenda + // --------------------------------------------------------------------------- + + const acceptedGenerated = generatedSubmissions.filter((row) => row.status === 'accepted'); + const scheduled = await db + .insert(scheduledSession) + .values( + acceptedGenerated.map((row, index) => { + // Walk rooms fastest, then slots, then days: the first day fills before the second, and + // each slot reads as a parallel track rather than as one room used back to back. + const roomIndex = index % openRooms.length; + const slotIndex = Math.floor(index / openRooms.length) % SLOTS_PER_DAY.length; + const dayIndex = Math.floor(index / (openRooms.length * SLOTS_PER_DAY.length)); + const start = new Date( + params.days[dayIndex]!.getTime() + SLOTS_PER_DAY[slotIndex]! * 60_000, + ); + + return { + eventId: params.eventId, + submissionId: row.id, + ref: existing.sessions + index + 1, + title: row.title, + descriptionMarkdown: row.descriptionMarkdown, + roomId: openRooms[roomIndex]!.id, + trackId: row.trackId, + formatId: row.formatId, + startsAt: start, + endsAt: new Date(start.getTime() + SLOT_MINUTES * 60_000), + status: 'published' as const, + icsUid: newIcsUid(), + }; + }), + ) + .returning(); + + // --------------------------------------------------------------------------- + // Reviews. A queue of a hundred untouched proposals reads as a broken screen rather than a busy + // one, so everything that reached a reviewer carries assignments and scores. + // --------------------------------------------------------------------------- + + if (params.review && params.review.reviewerUserIds.length > 0) { + const { roundId, reviewerUserIds, criteria } = params.review; + const reviewable = generatedSubmissions.filter((row) => row.status !== 'submitted'); + + const assignments = await db + .insert(reviewAssignment) + .values( + reviewable.flatMap((row, index) => + // Two reviewers each, walked round-robin so the load spreads evenly over the panel. + [0, 1].map((offset) => ({ + reviewRoundId: roundId, + submissionId: row.id, + reviewerUserId: reviewerUserIds[(index * 2 + offset) % reviewerUserIds.length]!, + status: 'completed' as const, + comment: + 'Well scoped, and the evidence is checkable. Would want the demonstration tightened.', + completedAt: ago(6), + })), + ), + ) + .returning(); + + if (assignments.length > 0 && criteria.length > 0) { + await db.insert(score).values( + assignments.flatMap((assignment, index) => + criteria.map((criterion, position) => ({ + reviewAssignmentId: assignment.id, + criterionId: criterion.id, + value: scoreFor(index + position * 3 + 1, criterion.maxScore), + })), + ), + ); + } + } + + await db + .update(event) + .set({ + submissionSeq: existing.submissions + generatedSubmissions.length, + sessionSeq: existing.sessions + scheduled.length, + }) + .where(eq(event.id, params.eventId)); + + return { + speakers: speakers.length, + submissions: generatedSubmissions.length, + scheduledSessions: scheduled.length, + }; +} diff --git a/docs/02-architecture.md b/docs/02-architecture.md index f9e8b6b5..54bab6df 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -295,8 +295,10 @@ a magic link is handing out a session for whatever address was typed into the bo transport those two are in direct conflict, which is what kept the deployment on `log`. The resolution is that the demo identities are undeliverable *by construction*, rather than delivery -being off for everyone. Both seeds are built entirely from IANA-reserved domains -(`organizer@example.com`, the senate at `@first-settlement.example`), and `sendMail` routes any +being off for everyone. Every seeded event is built entirely from IANA-reserved domains +(`organizer@example.com`, the senate at `@first-settlement.example`, and the generated rosters that +fill the three sized sample events at `@demo.example`, `@demo-small.example` and +`@demo-large.example`), and `sendMail` routes any recipient at a reserved domain to the log transport whatever else is configured — real addresses in the same run still get real mail, and the provider is never asked to bounce six hundred fictional senators. An on-screen link for such an address therefore cannot lock a real person out of anything, diff --git a/lib/demo-access.test.ts b/lib/demo-access.test.ts index cf44a2e9..725849ec 100644 --- a/lib/demo-access.test.ts +++ b/lib/demo-access.test.ts @@ -10,7 +10,7 @@ import { membershipsAreDemoOnly, } from './demo-access'; -const DEMO = ['demo', 'first-settlement']; +const DEMO = ['demo', 'demo-small', 'demo-large', 'first-settlement']; afterEach(() => { vi.unstubAllEnvs(); diff --git a/lib/demo-access.ts b/lib/demo-access.ts index 6673326c..dc9b4252 100644 --- a/lib/demo-access.ts +++ b/lib/demo-access.ts @@ -52,7 +52,13 @@ import { undeliverableRecipient } from './mail/config'; * greylist — an auth bypass triggerable by a stranger with a bounce. */ -const DEFAULT_DEMO_EVENT_SLUGS = ['demo', 'first-settlement'] as const; +/** + * Every event the seed creates. `demo-small` and `demo-large` are the same conference at other + * scales, and they are listed here for condition 4 above rather than as a convenience: a speaker + * generated onto one of them holds a membership, and an omission here would read that membership as + * "this identity has been let into a real event" and quietly close the demo sign-in for everybody. + */ +const DEFAULT_DEMO_EVENT_SLUGS = ['demo', 'demo-small', 'demo-large', 'first-settlement'] as const; /** Which events count as the demo. `DEMO_EVENT_SLUGS` overrides it for a differently seeded clone. */ export function demoEventSlugs(): string[] {