Program Resource
A Program is the resource that orchestrates the end-to-end distribution loop the other resources deliberately don't know about. It binds an audience dataset (a Sheet of people), an Email, and a Survey; issues one opaque token per participant; and joins those tokens back to responses into a per-participant status — served through the standard dataset contract, so a dashboard can chart the funnel like any other data.
The shape is Logic-Apps-positioned (orchestration is its own resource, the orchestrated resources stay pure) but deliberately not Logic-Apps-shaped: no trigger/action graph, no expression language — one domain, three bindings, one background-free run model. Extensibility lives in the content schema, not in a workflow engine.
How it works
flowchart LR
SETUP["Setup blade<br/>audience DatasetReference + key column<br/>emailId · surveyId"] -->|saveResourceContent| BLOB[("{id}/content.json")]
GEN["Generate participants (owner)"] -->|"resolve audience →<br/>one token per key value, idempotent"| INV[("ProgramParticipants table<br/>pk = programId, rk = sha256 of keyValue")]
GEN -->|"token map to the owner client"| EXPORT["participants CSV download<br/>key value · /view/Survey/{surveyId}?t={token}"]
EXPORT -.->|"sent outside the platform for now"| RESP["respondent"]
RESP -->|"?t= → createSurveyResponse"| SR[("SurveyResponseEntity.participantToken")]
STATUS["Status blade (owner-only)"] -->|"join participants × responses server-side"| FUNNEL["keyValue · addedAt · responded"]
STATUS -->|"ProgramStatus dataset<br/>(key column and token dropped)"| PUBSAFE["participant · addedAt · responded"]
PUBSAFE --> DASH["Dashboard visual<br/>(response rate)"]
- Content blob —
{ audience?: DatasetReference; emailId: string; keyColumn: string; surveyId: string }. Bare ids like every cross-resource link, re-resolved on read and failing soft when a binding is deleted.keyColumnnames the audience column identifying a participant (an email address, a customer id) — a display and dedupe key that never leaves the server or the owner client. - Participants —
AzureTable.ProgramParticipants, partitionKey = program id, rowKey = the sha256 of the key value, storing thatkeyValue, apublicId, thetoken, andcreatedAt. Generate participants resolves the audience dataset and creates one entity per distinct key value, idempotently: re-running after the audience grows issues only the missing tokens and never rotates an existing one, because a rotated token would dead-link a link already sent. - Participant links — the generate mutation is the one answer that carries tokens, and the Status blade hands it straight over as a download:
<name>-participants.csv, one row per participant with the key value under the key column's name and the survey link carrying their token, the file a mailer's merge takes, as Qualtrics' personal links are. Every participant is in it, not only the ones this run added, since a re-run never rotates a token already sent. Generating asks for a bound survey first, since a link opens it. The audience is read like any dataset, capped, so a person past the cap is not checked by that run; the run returns the read's truncation beside the participants and the blade warns how many audience rows went unchecked and may have no link — the same check the email's personalized export confirms on. A key value comes from the audience dataset — which may be a survey's anonymous answers — so every cell is written byescapeUntrustedCsvCelland a value a spreadsheet would run as a formula is neutralised. - Why the key is the key value, not the token — one person can hold only one token, and only storage can enforce that. Deriving the rowKey from the key value makes the insert itself the uniqueness check: a concurrent second generate loses with a 409 and adopts the winner's token instead of minting a rival. A random rowKey cannot do this — every racing write would be a distinct row, and no read-then-write above it can close the gap. The token stays a UUID in its own column precisely because it must be unguessable, and a key the caller cannot predict is a key storage cannot deduplicate on. The hash leaks nothing the row does not already store in plain text; it exists only because a rowKey cannot hold an arbitrary email address. Resolving a token back to a participant is therefore a single-partition scan rather than a point read — the identity owns the key, and only one of the two can.
- Status — participants × responses, joined server-side. The join matches on the
tokenand carries both thekeyValueand thepublicId, and each surface projects only the column it renders, so a participant identifier reaches a client only where that client displays it. Thetokenis the one field neither surface carries: it is a credential, and a response that ships it hands it to whoever reads the response.- the Status blade — owner-only, never a dataset; columns
keyValue · addedAt · responded, so the owner can see who hasn't answered —respondedmeans submitted, never a draft left after one answer — under a meter of the response rate so far. The owner is entitled to the tokens, but the blade renders none of them and a credential nothing displays is a credential the response has no reason to carry. - the
ProgramStatusdataset provider — columnsparticipant · addedAt · responded, whereparticipantis the non-secretpublicId, neverkeyValueand never the token. A dataset flows into dashboards, and a dashboard is publishable, so its snapshot is a public read. Putting the key column into the dataset would make publishing a funnel chart leak the participant list; publishing the token would hand every viewer the ability to respond as that participant. Response-rate charting needs counts and dates, not identities; anything genuinely per-participant is blade work, not chart work.
- the Status blade — owner-only, never a dataset; columns
- Blades — Overview, Setup (the three pickers, reusing
DatasetReferencePicker), and Status. There is no Editor blade: a program has no canvas. - Lifecycle — standard resource create/save/delete; the program's participant partition is declared through
ResourceOwnedTablesMap, which is whatpurgeResourcereads to clear it — a delete is soft and leaves the partition intact for the recycle bin window, so a restored program still knows its participants. Deleting the bound survey leaves status readable — participants persist, responses are gone — the same fail-soft posture as every dangling reference.
Procedures
| Procedure | Auth | Input | Purpose |
|---|---|---|---|
generateProgramParticipants | owner | { id } | resolve the audience, issue missing tokens, return the token map and the audience read's truncation |
readProgramStatus | owner | { id } | joined keyValue · addedAt · responded rows — no token, no publicId |
Plus the full createResourceProcedures(ResourceType.Program) set. Token validation on response writes lives in the survey router (response modes) — the program is the issuer, the survey is the gate.
Key files
| File | Role |
|---|---|
packages/db-schema/src/models/resource/ResourceType.ts | the Program type value |
packages/db-schema/src/models/program/ProgramParticipantEntity.ts | the participant entity + its key |
apps/web/shared/models/resource/program/ProgramResource.ts | audience/key/email/survey bindings |
apps/web/server/trpc/routers/program.ts | factory + participants + status |
apps/web/server/services/program/generateProgramParticipants.ts | idempotent token issuance |
apps/web/server/services/program/getProgramParticipantId.ts | the key value → rowKey derivation |
apps/web/server/services/program/readProgramStatusRows.ts | the server-only participants × responses join |
apps/web/server/services/dataset/programStatus/readProgramStatusDataset.ts | the ProgramStatus provider |
apps/web/app/components/Resource/Program/Setup.vue | the bindings blade |
apps/web/app/components/Resource/Program/Status.vue | the funnel blade, and the participant links download |
apps/web/app/services/resource/program/createParticipantLinksCsv.ts | key value and tokened link per participant |
apps/web/app/services/resource/sheet/csv/escapeUntrustedCsvCell.ts | formula neutralising for text the owner did not type |
Notes
- Naming. Program — generic enough to stay honest when it later orchestrates more than one email-and-survey wave, specific enough to read as "a thing that runs a plan". Considered and set aside: Campaign (marketing-suite connotation, ties the type to one use case), Flow (collides with the Flowchart resource), Workflow/Automation (promise a trigger/action engine this deliberately isn't), Journey (jargon).
- Naming. Participant — the row is a person in the audience holding a credential, and it exists whether or not anything was ever sent to them. Invite names the row after a message that does not exist yet: when email sending un-defers, the invite is the thing delivered to a participant, so the word has to stay free for it — and it is already the room-invite code in messaging. Considered and set aside: Recipient (presumes a send, drifting back to the same collision), Enrollment (accurate but formal, and the verb pair buys nothing here).
- Deliberately not a workflow engine. A general trigger/condition/action resource would need an event bus (rejected), background execution, and retry semantics — for one current domain. The program's content schema is the extension seam: reminder emails to non-responders, send scheduling, and the actual send run (when email sending un-defers, the program is its unit of execution) all extend this schema without a new platform layer.
- Sending remains outside the platform: the program produces correct tokened links, and delivery is manual. That keeps the whole feature free of new Azure services while building the exact foundation sending needs.
- The canonical "responses × audience" join is purpose-built rather than routed through a generic join engine, which keeps dataset joins deferred.
- One program per send-wave: re-running against a grown audience extends the same program; a genuinely new wave (new email, same audience) is a new program — Duplicate covers the setup copy.