Endatix.Modules.Audience
0.9.0
dotnet add package Endatix.Modules.Audience --version 0.9.0
NuGet\Install-Package Endatix.Modules.Audience -Version 0.9.0
<PackageReference Include="Endatix.Modules.Audience" Version="0.9.0" />
<PackageVersion Include="Endatix.Modules.Audience" Version="0.9.0" />
<PackageReference Include="Endatix.Modules.Audience" />
paket add Endatix.Modules.Audience --version 0.9.0
#r "nuget: Endatix.Modules.Audience, 0.9.0"
#:package Endatix.Modules.Audience@0.9.0
#addin nuget:?package=Endatix.Modules.Audience&version=0.9.0
#tool nuget:?package=Endatix.Modules.Audience&version=0.9.0
Endatix.Modules.Audience
Audience personalization for Endatix: per-form properties and people, tenant match key, CSV import, and personalised links.
Endatix Platform is an open-source data collection and management library for .NET.
Registration
Gated by Endatix:FeatureFlags:PersonalizationModule (off by default). Runs on PostgreSQL and on SQL Server 2025
or later (or Azure SQL Database). EndatixBuilder.UseDefaults() calls UseModule(AudienceModule.Instance).
The module registers AudiencePostgreSqlDbContext or AudienceSqlServerDbContext for the configured provider;
handlers see it only as IAudienceDbContext. On SQL Server, keep READ_COMMITTED_SNAPSHOT on for the database (on in a
database Endatix creates and in Azure SQL Database; a database created beforehand may have it off): without it, other
tenants' audience reads wait for a running CSV import to commit.
Migrations
Each provider has its own chain in this assembly: Persistence/Migrations/PostgreSql/ and
Persistence/Migrations/SqlServer/. Run from the repository root:
# PostgreSQL
ConnectionStrings__DefaultConnection="Host=localhost;Database=endatix;Username=postgres;Password=..." \
dotnet ef migrations add <MigrationName> \
--startup-project src/Endatix.WebHost \
--project src/Endatix.Modules.Audience \
--context AudiencePostgreSqlDbContext \
--output-dir Persistence/Migrations/PostgreSql
# SQL Server
ConnectionStrings__DefaultConnection="Server=localhost;Database=endatix;User Id=sa;Password=...;TrustServerCertificate=True" \
dotnet ef migrations add <MigrationName> \
--startup-project src/Endatix.WebHost \
--project src/Endatix.Modules.Audience \
--context AudienceSqlServerDbContext \
--output-dir Persistence/Migrations/SqlServer
A model change needs a migration in both chains, and follows
Schema changes during a rolling deploy: the previous
release keeps writing while the new one migrates, so a constraint on a new column ships one stable release after the
column. AudienceMigrationsRollingDeployTests catches a single migration that adds both.
AddPropertyValueIsTest (endatix#1376) copies each membership's test flag onto its cells, then adds
FK_PropertyValues_Membership (TenantId, MembershipId, IsTest → AK_Memberships_TestFlag on Memberships), which
validates every existing cell. From then on the database refuses a cell whose tenant or test flag differs from its
membership's. An older API version writes IsTest = false on a Test participant's new cell and fails with the foreign
key (a 500). The migration adds the column and the key together only because the module was a preview until then
(personalization-module, off by default), so no production host wrote PropertyValues before it; a host that turned
the preview on sees that 500 while the upgrade rolls out (endatix#1389). The column add is metadata-only on
PostgreSQL 11+ and SQL Server Enterprise/Azure SQL, and a table rewrite on SQL Server Standard; the index rebuilds
block writes to PropertyValues while they run. This is the module's only foreign key; nothing hard-deletes a
membership. A future purge must delete its cells first and add an unfiltered index on (TenantId, MembershipId,
IsTest) before it: the key's checks also see soft-deleted cells, so the filtered IX_PropertyValues_Cell cannot serve
them, and each membership delete scans PropertyValues (22 ms per membership at 500,000 cells on PostgreSQL 17, every
page on SQL Server 2025).
Schema
Database schema: audience
| Table | Purpose |
|---|---|
Settings |
One row per tenant: match key (email / external_id). No row means email |
Properties |
Property definitions per form (VariableName immutable). Choice types need ChoicesJson, a JSON array of distinct keys; other types take no choices |
Members |
One person per tenant + match key. Identifier keeps the value as entered; NormalizedIdentifier is the unique match key. Emails must be one valid address and use the same normalizer as app users; external ids keep their case. On SQL Server the match key uses the binary collation Latin1_General_100_BIN2, so it compares case-sensitively as on PostgreSQL |
Memberships |
Person on one form. IsTest marks a test person; it is set when the person is added and never changed |
PropertyValues |
Property cells on a membership. Each value is at most 4,000 characters and must fit the data type (see below). A stored value is never empty: saving "" clears (soft-deletes) the cell. IsTest is the membership's test flag, copied when the cell is written (it never changes on either), so coverage counts test and real cells from IX_PropertyValues_Property (tenant, Property, test flag) with no join. FK_PropertyValues_Membership ties the cell's tenant and test flag to its membership; cells are built only through PropertyValueCreateArgs.For(membership, …) |
Imports |
One row per CSV import: counts, file name, who ran it. A staged import is also its run's state machine (uploading → checking → checked → writing → completed, or canceled, failed, expired), with the mapping fixed at start, the match key the Check pinned, and the write checkpoint WrittenThroughRow. Status and WriteRun are the concurrency tokens. RunsInRequest (default false) marks a one-request import. IX_Imports_Unfinished allows one unfinished import per form. Deleting the form cancels its import that is uploading or checked; one in checking or writing fails in its own run |
ImportChunks |
A staged import's uploaded rows, one row per chunk (IX_ImportChunks_Index): raw cells in header order, as JSON. A row may carry its own spreadsheet number (a file parsed on the server, where a blank line skips one); otherwise it follows the row before it. Numbers still increase in chunk-index order. Deleted when the import completes, by the sweep once it finishes otherwise, or when its form is deleted |
ImportRejections |
Rows an import will not write (IX_ImportRejections_Row), with the reason and the cells as uploaded. The sweep deletes them 7 days after the import finished |
Links |
One hashed token per membership (IX_Links_Membership). SubmissionId is set once, on first open |
API (directory)
| Method | Route | Notes |
|---|---|---|
| GET/PUT | audience/settings |
Tenant match key, isLocked, and importLimits (maxRows, maxBytes, runsInBackground): 100,000 rows and 50 MB where imports run as background jobs, 5,000 rows inline |
| GET/POST | forms/{formId}/audience/properties |
List / create |
| PATCH/DELETE | forms/{formId}/audience/properties/{propertyId} |
Rename/reorder / delete |
| GET/POST | forms/{formId}/audience/people |
List (page ≤ 5,000) / add. The list takes test=all\|real\|test (default all, anything else is a 400) and search (people whose identifier contains it, ignoring case: ILIKE on PostgreSQL, the column's collation on SQL Server: Identifier takes the database's default collation, so the search ignores case only on a case-insensitive database (the SQL Server default), and is case-sensitive on one created with a _CS_ or binary collation; %, _ and [ match literally; blank or whitespace is no search; longer than an identifier after trimming is a 400); its total counts only the people kept, and each item has isTest. Add takes isTest (default false). Each list item has notFilledIn: how many of the form's Properties the person has no value for (the list only; the single participant, create and update responses leave it out) |
| PUT/DELETE | forms/{formId}/audience/people/{membershipId} |
Edit values / remove from this form only |
| POST | forms/{formId}/audience/people/test/removal |
Remove test participants: {membershipIds, alsoDeleteTestSubmissions, dryRun} → 200 {people, testSubmissions, dryRun}, the counts this request removed (with dryRun, would remove, writing nothing). membershipIds null is every test participant on the form, else 1–500 distinct ids. Forms.Edit; alsoDeleteTestSubmissions also needs forms.test and submissions.delete (403, checked by the handler, a dry run too). A real participant in the set is 400 and an id not on the form 404, both before anything is removed. See Removing test participants |
| GET | forms/{formId}/audience/people/{memberId} |
One participant, by member id (not membership id): the list item's fields plus submissions and submissionTotal. The submissions are theirs on this form: the one their live link opened, their Submitter's, and Snapshots that name them and are either a test submission or an on-behalf create (PersonalizationSnapshotJson.NamesParticipant plus IsOnBehalf, exact lookups of the Snapshot's top-level memberId and source keys). On-behalf is included through the Snapshot so a create whose best-effort Submitter bind did not run is still theirs. Newest first (created, then id), at most 50; submissionTotal counts them all. Deleted ones are left out. Each has id, createdAt, completedAt, isComplete, status, isTest, and snapshotSource (link, on_behalf, test), left out without a Snapshot. Not on this form, removed, or another tenant's is 404 |
| GET | forms/{formId}/audience/variables |
The form's Variables for the designer, in SurveyJS's variable presets shape (3.1.1+): { variablePresets: { definition, presets, totalPresets } \| null, coverage, editing, readOnlyPresets }. definition is survey JSON with one top-level question per Property in tab order (see Rules → Variables), each also carrying prefillTargets, the question types it can prefill ([{ type, inputType? }], additive); presets has up to 20 participants, Test participants first and then real ones (see Rules → Variables), each with name their identifier, description Test participant or Participant, and variables their live values typed by AudienceValueJson.Typed, the function the Snapshot uses. totalPresets counts every participant on the form, test and real. readOnlyPresets names the real participants among the presets, in list order, so the designer locks them (onVariablePresetEditing); it is empty when the form has no Properties. variablePresets is null when the form has no Properties. coverage counts the form's test and real participants (participants, also when it has no Properties) and, per Property in tab order, those with a value (properties[]: variableName, filledTest, filledReal, zero when nobody filled it); editing (allowAdd, allowEdit, allowDelete) is true, true, false (Test participants are added and edited from the designer, none deleted), all false when the form has no Properties. Another tenant's or a missing form is 404 |
| GET | forms/{formId}/audience/variables/participants/{memberId} |
One participant's preset for Preview as, by member id (not membership id): { name, description, variables }, built as a preset in the variables read (name = identifier, description Test participant or Participant, values typed by AudienceValueJson.Typed, one entry per live cell of a current Property). Any participant on the form, real or test. Not on the form, removed, or another tenant's form is 404 |
| PUT | forms/{formId}/audience/variables/test-participants |
Save the designer's edited presets: { presets: [{ name, variables }] }, the whole list (≤ 100), each name an identifier. Returns the variables read again (200). See Rules → Variables write-back. A change to a real participant (one sent back unchanged is left alone), an identifier the match key refuses, a value its Property refuses, or one identifier given twice is 400 and writes nothing; a parallel add of the same new participant is 409 |
| POST | forms/{formId}/audience/import |
CSV up to 5,000 rows, run in the request as a staged import (see Rules → Staged imports), with the same request and response. Rejected rows (blank, too long, duplicate identifier) are reported by spreadsheet row, not written. A form with an unfinished import is 409, naming it (importId), a file with no data rows included; such a file is recorded as an import that wrote nothing. An import that cannot finish in the request (the form's people are busy, a race lost twice, an error, the client gone) is canceled, or failed once it has started writing, so the form is free at once; it is not resumable, since the caller sends the whole file again. An empty mapped cell, and a choice Property with no ticked __ column, keep the stored value, and a Property the mapping leaves out is untouched: the one-request import always runs as emptyCells: keep, missingProperties: keep. A mapping that names an unknown property or a missing column is a 400. With isTest, people new to this form are added as test persons; people already on it get no writes (flag and values) and are counted as skipped |
| POST | forms/{formId}/audience/imports |
Start a staged import: { fileName, headers, rowCount, identifierColumn, propertyColumns, isTest, emptyCells, missingProperties } → 201. emptyCells and missingProperties are keep (default) or clear, fixed on the import with the mapping, and every write batch, a resumed one included, applies them to people already on the form (see Rules → Staged imports). Too many rows, no identifier column, a mapping that does not fit the form, or an option other than keep or clear is 400. A form with an unfinished import is 409, naming it (importId). A start expires the form's stopped writes that could resume, in the same save, so they can no longer resume (409). See Rules → Staged imports |
| PUT | forms/{formId}/audience/imports/{importId}/chunks/{index} |
One chunk: { firstRowNumber, rows: [{ cells, malformed }] }, upload.chunkSize rows (fewer in the last). Sending it again replaces it. The chunk that completes the file starts the Check. An import no longer uploading is 409 |
| GET | forms/{formId}/audience/imports/{importId}, …/imports/current |
The import: status, fileName, headers (the header row fixed at start; a file chosen again to resume an upload must match it, since chunks are positional), upload (rowCount, chunkSize, missingChunks), checkedRowCount, check (newCount, existingCount, rejectedCount, once the Check has read every row), written, the first 50 rejections, failure, canResume, jobId, and the emptyCells and missingProperties it started with. current is the form's unfinished import, else its newest stopped write that can resume (failed, canResume), else 404 |
| GET | forms/{formId}/audience/imports/{importId}/rejections |
Every row the import did not write, paged (page, pageSize default 50, at most 5,000) in the standard envelope (items, page, pageSize, totalRecords, totalPages), by spreadsheet row: rowNumber, reason, and cells as uploaded in the order of headers. Kept until the sweep deletes them, 7 days after the import finished. Another form's or a missing import is 404 |
| PATCH | forms/{formId}/audience/imports/{importId} |
{ status: "writing" } confirms a checked import or resumes a stopped write; { status: "canceled" } stops one that has written nothing, or abandons a stopped write. Any other move is 409, as is a resume while another import on the form is unfinished, or of a stopped write a later start expired |
| POST | forms/{formId}/audience/links |
Issue one link per real person who does not have one, up to 5,000 real people; test persons get none. The raw token is in this response only. A parallel issue loses on IX_Links_Membership and retries once |
| GET | forms/{formId}/audience/links/preview |
search as the people list takes it. Returns matching (real people the search keeps), withoutLink (those of them with no link: who a batch would issue to) and cap (5,000). Two COUNTs over memberships and members; no cells are read |
| POST | forms/{formId}/audience/links/batches |
Body { filter: { search } } (no filter or a blank search keeps everyone). Issues one link per real person the filter keeps who has none, test persons never. Returns matching and issued: [{ membershipId, identifier, token }], new links only, so a repeat issues none. The cap counts the links this call would issue, not the people on the form: over 5,000 is a 400 ("N people would get a link; the limit is 5,000 per batch. Narrow the filter.") and issues none. PeopleFilter (PeopleQuery.Matching) is the one rule the list, the preview and the batch share. AudienceLink.Issue is the only minter. A parallel batch loses on IX_Links_Membership and runs again for the people still without a link; batches with different filters can keep overlapping, so it tries up to 5 passes, then answers 503 (try again). The preview's two counts share no snapshot, so withoutLink is clamped to matching |
| POST | forms/{formId}/audience/people/{membershipId}/link/reissue |
forms.edit. Gives one person's link a new token → 200 {membershipId, token}, the raw token in this response only. The old token no longer opens the link (redeem 404). An opened link keeps its submission, so the new token resumes it; the access tokens earlier opens handed out are refused (400, token_revoked) within 1 minute (the cached gate check, per node). Only the token hash is written, and only while it is still the one the reissue read: a first open running meanwhile keeps its claim, and a reissue another one overtook is 409, so no 200 carries a dead token. A test person is 400. A person not on this form, removed, or without a link yet is 404 |
| POST | forms/{formId}/audience/test-submissions |
Test as this person: {membershipId} → 201 {submissionId, token}. Every call opens a new test submission (see Rules → Test submissions). A disabled form, a form without a definition, or a person not on the form is 404. An open while the form is being deleted is 503; an open after the deletion, or after the form was disabled meanwhile, is 404 |
| POST | forms/{formId}/audience/test-submissions/{submissionId}/restart |
Start over: {token} → deletes that test submission and opens a new one for the same person → 201 {submissionId, token}. token is the one the test submission was opened with; a missing token is 400, a wrong one 404. A submission that is not a test submission opened as a person is 400; a person removed from the form is 404 and keeps the old one. A Start over while the form is being deleted is 503, and after the deletion 404; either way it opens nothing |
| POST | forms/{formId}/audience/test-links |
Test links: {membershipIds \| null, runsEach, expiryMinutes} → 200 {items: [{membershipId, identifier, run, submissionId, token, expiresAt}]}, by membership, then run. Needs forms.test and forms.edit, and no submissions permission. membershipIds: null means every Test participant on the form; none gives items: []. runsEach is 1–10, expiryMinutes 60–86,400, and more than 500 test submissions in a call is 400. A real participant in the set is 400, and a person not on the form is 404. Every refusal opens nothing (see Rules → Test links) |
| POST | forms/{formId}/audience/links/{token}/redeem |
Anonymous, Throttle(60, 60) (in-process, per client: its X-Forwarded-For, else its connection's address; over the limit is 429, and a request with neither is 403). First open pins the active form definition and the live audience cells on one in-progress submission. A later publish, a new property, or an edited cell does not change that response. The submission it starts shows the person's label (submitterDisplayId): the label of the Submitter bound to them, else their identifier. A parallel first open keeps one submission. A later open returns it. Unknown token, a disabled form, or a person removed before the first open is 404 and does not mark the link opened. A deleted submission is 404, unless it was a test. A link whose submission was marked as test, deleted or not, opens a new one: redeem clears that claim itself (only while the link still names that submission) instead of waiting for the outbox. A person removed after open is 404 too, and nothing is written: their access token is refused. A link whose submission has ended (complete or screened out) is 409 with errorCode submission_ended and its submissionId, and nothing is written, unless the tenant keeps tokens valid after completion (then it resumes). On a one-response form, a person who already has a submission (from on-behalf, or an earlier link) opens that one; it is marked started and gets the live cells if it has none. Another open of the same link holding it past 5 s is 503, as is an open while the form is being deleted; an open after the deletion is 404. Response is submissionId, created, accessToken (deprecated: the submission's opaque SubmissionToken, which follows the form's privacy), submissionAccessToken, submissionAccessTokenExpiresAt — not the snapshot. submissionAccessToken is a signed submission access token with submit (fill in and complete), sent as tokenType=AccessToken, on a public or private form, with no login. It lasts the form's response-token lifetime (form, else tenant). A signed token always expires: a lifetime that never expires, or one past 60 days, gets the 60-day cap (ISubmissionAccessTokenService.MAX_EXPIRY_MINUTES); a tenant with no settings gets TenantSettings.DefaultSubmissionTokenExpiryHours (24 h). It follows the submission token's after-completion rule: once the submission is complete or screened out it is refused (400, token_revoked), unless the tenant keeps tokens valid after completion. It is bound to the link: after a reissue, or once the person is removed from the form, it is refused (400, token_revoked) within 1 minute (a cached check); a disabled or deleted form refuses it at once (404). accessToken is removed in a later release |
Permission: Forms.Edit, except PUT audience/settings, which needs Tenant.ManageSettings, the test submission
routes, which need both Forms.Test and Forms.Edit (a test submission carries the person's identifier and values),
and redeem, which is anonymous.
Rules
Staged imports. The browser parses the file and sends it in chunks of 1,000 rows. The chunk that completes the file starts the Check, which reads every row with the reader the one-request import uses (
ImportRowReader, one instance across chunks, so a repeat in a later chunk is a duplicate), writes the rejections, counts who is new to the form, and writes no Participant. It pins the match key and starts again at row 1 when retried. After the scripter confirms, the write commits 500 rows per transaction under the shared match-key lock and every stripe of the form's people lock, withWrittenThroughRowin the same transaction, so a write cut off anywhere continues after its last batch and writes no row twice. It skips the Check's rejections and re-reads values against the Properties as they are then. Each batch re-checks the form and the match key: a deleted form fails the import and runs the form's cleanup again (a batch may have committed after it), and a changed match key fails it whole, which is safe because a key can only change while the tenant has no Participants. With the Jobs module, each phase is a background job (AudienceImportCheck,AudienceImportWrite), enqueued after its transition commits with a dedup key per phase and run; a read that finds a running phase with no job enqueues it again. Without it, the same phases run in the request, up to 5,000 rows. A phase its request leaves running (a closed page, a timeout, an error, a busy audience) has no job to continue it, so it endsfailed: a stopped write withcanResume, a stopped Check without. The one-request import runs its phases itself, not through that runner, and marks its importRunsInRequest, so a read never enqueues a job beside it; a resume runs as a job again. A cancel is seen by the Check at its next chunk, because every save is fenced on the status.Import options. A staged import starts with
emptyCellsandmissingProperties(keepby default, stored asClearEmptyCellsandClearMissingProperties, both default false) and every write batch reads them from the import, so a resumed write applies the same ones. They change only people already on the form, never one a test import matches (it writes nothing to them). A Property is in the file when the mapping names it or the file has one of itsvariable__choicecolumns; a row with an empty cell for it, no cell at all (a row shorter than the header row), or no ticked choice column, leaves it empty.emptyCells: clearclears what the file's Properties store for a row that leaves them empty, andmissingProperties: clearclears what the form's other Properties store for each person in the file; people not in the file keep everything. Both only clear the Properties the form had when the import started (CreatedAt), so a Property added during the import or before a resume is never cleared by it. The match-key Property is never cleared.ImportClearspicks those Properties and adds an empty value only where a cell is stored, so a person counts as updated only when a cell changed or was removed, and a file re-imported withclearand nothing left to clear reports them as skipped. The Check's counts do not depend on the options.Import sweep.
ImportSweepends the tenant's idle imports before every import start, so an abandoned import never holds a form, andImportSweepServiceruns it for every tenant five minutes after the host starts and then daily, on every node, also deleting what finished imports staged. An import leftuploadingorchecked24 h expires (a chunk sent counts as activity), acheckingone 6 h fails, and awritingone 6 h fails resumable: the state a write whose job dead-lettered or hung leaves, so the scripter resumes it from its checkpoint. A resumable failure nobody resumes for 7 days expires, and so does every one of a form when an import of that form starts, in the same save as the start: the scripter chose to start over. Until thenimports/currentreturns the form's newest one, so Resume is offered after the fact. Idle isModifiedAt, elseCreatedAt; no column was added. Each move is the aggregate's, saved against the status it read, so nodes sweeping at once, or a run moving the import meanwhile, settle as compare-and-set. Finished imports lose their chunks, and their rejections 7 days after they finished, one import per delete.WriteRunis a concurrency token next toStatus, and a write job carries the run it was enqueued for: a batch of a run that was failed as stalled and then resumed writes nothing, wherever it wakes. A job queued before runs were named writes for run 0, and a write started without a run (in a request) takes the import's run when it starts, so no write runs unfenced.Match key. Locked while any person is on any form's audience (
isLocked). Saving the current key is a no-op and takes no lock. A change soft-deletes members that are on no form, since they were matched under the old key.Values by data type.
number: invariant, no surrounding spaces, any finite double (-12.5,1e3,1e29).boolean:trueorfalse.date:YYYY-MM-DD.date_time: ISO 8601 (2026-10-05T09:30, optional seconds and offset).single_choice: one key fromChoicesJson.multiple_choice: a JSON array of keys.AllowsOtheraccepts any key.Choice columns in an import. A
variable__choiceheader whose variable is a choice Property is one choice of it (CsvColumnMapper,ChoiceCells), so a responses export imports back unchanged, unless the caller mapped that header to a Property; a single-choice Property may also keep one column holding the key. A choice cell ticks with1,true,yes,yorxand stays unticked with0,false,no,nor nothing, in any case; any other value rejects the row, naming the column. On a multiple-choice Property,variable__otherandvariable__other_textare Other (endatix#1418): the text is added as a free choice only when the Property allows Other and__otheris ticked. Text without the tick, or on a Property without Other, rejects the row. A ticked__otherwith an empty__other_textcell is ignored; a ticked__otherwhen the file has no__other_textcolumn rejects the row. A Property that lists a keyotherkeeps__otheras that key, and its__other_textadds the text beside it under the same conditions. Without Other, a ticked__otheris the keyother, which the value check refuses. Other text that equals a listed key is stored once, as that key. A single-choice Property skips its__other_textcolumn, so a radio question's export imports, but its Other text is not stored yet. The Check and the write read rows through the sameImportRowReader, so staged imports and the one-request route share the rule.Concurrency. Person creates and imports take a shared tenant lock; a match-key change takes it exclusively. The lock is
ITransactionLock(scopeAudienceMatchKey, key the tenant id), held until the transaction ends. The unique indexes settle create races (a second pass reuses the member; a duplicate on one form is a 409). Two imports of one form that add the same new people in different orders can deadlock on those indexes; the victim takes the same second pass. The identifier is checked against the key read under the lock. An edit of a participant's values and their removal both take that participant's stripe of the form's people lock exclusively, then the participant's lock (MembershipLock: scopeAudienceMembership, key the membership id), and read the membership again under them: an edit that waited for a removal is a 404, and a removal that waited for an edit soft-deletes the cells it wrote, so a removed participant never keeps a live value (endatix#1361). The CSV import writes values of people already on the form in one transaction, so it takes every stripe of the form's lock shared (scopeAudienceFormPeople), after the tenant lock and before it reads the form's people. The form's lock is 16 stripes, keyed{formId}:{stripe}: an edit and a removal hold their participant's stripe (from a hash of the membership id) exclusively, an import holds all 16 shared, in ascending order within one 5 s wait. So imports of one form run together (endatix#1383), every import excludes every edit and every removal on the form, and edits and removals run together unless they share a stripe, when one waits for the other for the length of that section. Lock queues are first come, first served, so an import that asks for the stripe of an edit or a removal queued behind another import waits until that writer gets it or gives up. A removal that waited for an import soft-deletes the cells it wrote, and an import that waited for a removal adds the participant again. An edit waits for an import on its stripe (endatix#1384): without that stripe, the edit's insert waited on the import's uncommitted cell for the 30 s command timeout while holding the participant's lock, and then failed with a 500. An edit that loses a cell race to a writer that skips the locks takes a second locked pass, which overwrites. Every wait is 5 s, then 503 whosedetailis the message of the lock that waited: "This participant is being changed. Try again." for the participant's, "People on this form are being imported or changed. Try again." for the form's (an edit or a removal behind an import or another edit or removal on its stripe, or an import behind an edit or a removal). On PostgreSQL two keys of one scope can share ahashtextlock, so unrelated people or forms then wait for each other, within that bound. Cells are written in property-id order. The people list counts and then reads the page without a transaction, so it takes no locks that wait behind writers; when a person added in between makes the page longer than the count, the total is raised to cover the rows shown.Test persons. A test person is on one form only for testing: never issued a personalised link, and shown with
isTestso the list can filter them. The flag belongs to the membership, so the same member can be a test person on one form and a real person on another. It is set at add (isTeston add or import) and cannot change: to turn a test person into a real one, remove them and add them again. An import marked as test never touches a person already on the form (neither its flag nor its values), so real people's data stays as it was. A test person locks the match key like any other person. Removing a test person removes only that membership.Deletes. Removing a person deletes the membership and its cells; the member stays. Deleting a property soft-deletes its cells in one statement. Reads skip cells of deleted properties. These take no participant lock: an edit in flight can still write a cell after them, and it stays out of every read with its Property (a form's deletion deletes its Properties too).
Removing test participants.
RemoveTestPeopleHandlerremoves the people first, one at a time, throughTestParticipantRemoval: each one throughPersonRemoval(the removalDELETE people/{membershipId}makes) under their own stripe andMembershipLock, in a transaction of its own; no section holds the whole form. Then, withalsoDeleteTestSubmissions,TestSubmissionSweepdeletes the test submissions opened as them:TestSubmissionsOfSpec(a test submission whose Snapshotsourceistestand whosememberIdis one of theirs,PersonalizationSnapshotJson.IsTestandNamesAnyParticipant), in keyset pages of 500, each deleted through the aggregate so Reporting drops its row, and each its own commit. A submission marked as test from the submissions page keeps its own source and is left alone. Their test links then answer 404, as any access token to a deleted submission does. The named memberships are read with their tombstones, so the same request again finishes a run that failed part way (people already removed count 0; an id never on the form is 404); withmembershipIdsnull that is every test participant ever on the form, so its cost grows with the form's history and the request runs until both phases finish. The Snapshot names the member, not the membership, so a member still on the form after the removals through a membership the request did not name (a real participant, or a test one added again) keeps every test submission: a real participant never loses one. Known window: a test open that found the person before their removal and saves after the sweep leaves one test submission, whose link works until it expires.Form deleted. The
form.deletedoutbox handler soft-deletes the form's cells, memberships and properties; tenant members stay. Until it runs, the form's people still count toward the match-key lock.Marked as test. The
submission.updatedoutbox handler (changeKindtest) frees the link of a submission still marked as test, deleted or not, keeping the token. It locks the submission's row while it clears the link, so a mark as real cannot commit between the check and the clear. Redeem frees such a link itself too, so the handler only tidies the link early. Marking a submission real is a 409 naming the other one when its link has since opened a real submission, and a 400 for a test opened as a person (Test as this person): its answers are a builder's, and as a real response it would pass every one-per-person check.Outbox delivery. Both outbox handlers also run as background jobs (
AudienceDeleteFormAudience,AudienceReleaseLinkOnTestMark), each registered with its job and subscription by oneAddOutboxWorkcall inAudienceOutboxJobs. A handler registered inline only stops a host withEndatix:Outbox:DeliverToJobQueueon from starting. The release job is enqueued only for a test mark, not for everysubmission.updated.On behalf.
POST forms/{formId}/submissions/onbehalf(Submissions.CreateOnBehalf, not this module's routes) freezes a snapshot whenSubmitter.ExternalSubjectIdmatches a member on a form that has properties. Unknown id, no properties, or no submitter leaves the create unchanged. The snapshot issource: on_behalfplusmemberIdandvalues[]besidevariables. BindingMember.SubmitterIdis one update that matches only while it is null.Test submissions.
TestSubmissionOpeneris the one way to build one: a started submission on the form's active definition,IsTestSubmission, no Submitter and no single-response gate, labelled with the person's identifier, and a snapshot of the person's current cells withsource: testandmemberId. It never reads or writesLinks,Member.SubmitterIdorSubmitters, so testing as a person leaves their personalised link unopened (or still pointing at their real response) and their one-response gate as it was. The person is found throughLinkOpenGates.PersonAsync, the query a link open uses, with the tenant and form stated in the read. The values freeze withSubmission.FreezeForTest, which refuses a real submission or a second freeze. Start over needs the submission's token, so one builder cannot discard another's test. It finds the person by the snapshot'smemberId, checks they are still on the form, and reads their values and mints the token before it deletes anything. An open and a Start over then write throughTestSubmissionWriter, which takes the form's sharedFormSubmissionsLock(no wait: 503 while the form is being deleted), reads the form again (enabled, with an active definition, else 404), and inserts on the definition active now; the locked section reads nothing else, so it holds one connection. A Start over deletes the old submission in the same save as the insert:IsDeletedis a concurrency token, so the delete matches only while the old one is live, and of two Start overs at once one replaces it and the other gets 404 and saves nothing. A save that loses a deadlock is 503. A failure to mint the token saves nothing and the old test submission stays. Test submissions are left out of exports unless the export asks for them.Test links.
TestSubjects.CandidatesAsyncreads the form first, then the named people (or every Test participant) in one read, each with its test flag.CreateTestLinksHandlerrefuses a real participant itself, becauseTestSubjectsfinds anyone on the form, and it checks the 500 cap before anything is written.TestSubmissionOpener.OpenManyAsyncreads the token lifetime once and each person's values once.TestSubmissionWriter.AddManyAsyncholds the sharedFormSubmissionsLockonce and inserts every submission in one save. It then mints each token throughISubmissionAccessTokenServicebefore it commits, so a token that can't be minted rolls the call back. Each token is unstamped andsubmitonly, as Copy test link's is. It works until it expires, or until its submission or form is deleted (404). A retry of a call whose response was lost opens a second set.Participant on a form.
FormParticipantsis the one rule:ByMemberfinds a member's live membership on the tenant's form, andOfjoins memberships to their members by key, with the member's tenant filter dropped by name (the membership is the tenant's; with the filter, PostgreSQL can scan the tenant's members per membership). The participant panel (ParticipantReader), Preview as (AudienceVariablesReader.ReadOneAsync), opening a test submission by member id (TestSubjects) and a link open (LinkOpenGates.PersonAsync) all go through it.ParticipantValuesis the one values read (live cells by property id), used by the people list, the participant panel and Preview as.Match-key Property. The form's
textProperty whose variable name is the tenant's match key (emailorexternal_id,MatchKeyProperty.Of, the key read in the same statement throughIdentifierKindReader.Stored, email when the tenant has no settings) holds each participant's identifier wherever values are read: the people list (values, sonotFilledIncounts it as filled), the participant panel and the create and update replies, Preview as and the variables read (ParticipantValues, which takes the Property from its caller), Coverage (filled for every test and real participant) and the Snapshot of new submissions (SnapshotCells, given the form and identifier by the open). It is derived, never stored, and the identifier wins over a cell stored for that Property; Snapshots already written are not changed (endatix#1365). Only atextProperty: the identifier is the text the participant was added with, andTypedwould turn an external id007in anumberProperty into7, or put an address in a date or a choice its rules refuse, so a Property of another data type namedemailorexternal_idis an ordinary one. Writes follow the read-only real presets (endatix#1359): a value for the match-key Property that is the identifier or empty is left out and stores nothing, and any other value is refused, naming the Property ("Email is the participant identifier and cannot be changed."), by one rule (MatchKeyProperty.WriteError) in the participant panel (CreatePersonCommand,UpdatePersonCommand: a 400), the designer's presets (PresetReconciler, before the first write: a 400) and the CSV import (ImportRowReader: the row is rejected).Variables.
AudienceValueJsonis the one place a cell becomes the JSON a survey reads: the Snapshot (variablesandvalues[]) and the designer's presets both callTyped, so a value previewed for a Test participant is the value frozen on their next test submission. A number that is not finite stays a string, because JSON cannot carry it.VariableDefinitionis the only data type → question mapping:textanddate_time→text(a date-time's own question stays plain text; the client converts the stored value, offset dropped and wall-clock time kept, before it fills adatetime-localinput),number→textwithinputType: number,date→textwithinputType: date,boolean→boolean,single_choice→dropdown,multiple_choice→checkbox; choices are the keys in order, andAllowsOtheraddsallowCustomChoices(amultiple_choicebecomes atagbox), which stores free text as the Audience does. Each question also carriesprefillTargets, the form questions whose answer has the shape the Snapshot gives that data type, so the designer's Prefill from offers a Property only where it fits and the Hub keeps no type table of its own:text→text,comment;number→textwithinputType: number,rating,slider;boolean→boolean;date→textwithinputType: date;date_time→text, andtextwithinputType: datetime-local;single_choice→dropdown,radiogroup,buttongroup;multiple_choice→checkbox,tagbox, whether or not it allows other.inputTypeis omitted whenever the question uses its default; fortype: text, that default is plain text. SurveyJS does not know the key: loading the definition lists it injsonErrorsand leaves the question as it was, and a loaded question drops it, so a client reads it from the response itself.AudienceVariablesReaderreads the Properties, the first 20 participants (Test participants first, each group by when they were added: one ORDER BY IsTest DESC, Id), and their live cells (by the membership ids just read and the live Property ids, one table and a seek per participant onIX_PropertyValues_Cell; anEXISTSon Properties there scanned every cell of the tenant), and the coverage counts: participants by test flag, then filled cells in one GROUP BY on Property and the cell's test flag over the form's live Properties (one index range onIX_PropertyValues_Propertyeach), with no join between cells and memberships, which timed out at 30 s in CI at 5,000 × 50 (PostgreSQL looped the memberships over the cells). The statements share no snapshot, so each count is clamped to0..participants. The membership filter has no EXISTS on Members, as the people list does: the only Member delete (RetireMembersAsync, on an identifier-kind change) runs only while the tenant has no live memberships. A cell exists only while it holds a value, so a count of cells is a count of people filled in. Up to six statements; it stops after the participant count when the form has no Properties or no participants. At 5,000 Test and 5,000 real participants × 50 Properties (495,000 cells) a warm read takes a p50 of about 55 ms on PostgreSQL and 150 ms on SQL Server in the integration test (fresh statistics; 190 and 140 ms stale), in the same statements as a small form (endatix#1376).ReadOneAsyncreads one participant, real or test, with the Properties and their live cells, in four statements (the participant, the match-key Property, the Properties, their cells); it stops after the first when they are not on the form. The presets list those Test participants by identifier, then the real ones by identifier, so a form with no Test participants still previews as its first 20 participants.totalPresetscounts every participant, test and real (CSV import can add up to 5,000). A choice key cannot contain|, because SurveyJS splits a choice written asvalue|text.Variables write-back.
SaveTestParticipantValuesmatches each preset to a participant on the form by identifier, normalized by the tenant's match key (an email ignores case).PresetReconcilerplans every write before the first one: a new identifier is added as a Test participant with its filled values; a Test participant gets only the values that differ from their cells, compared throughTypedthenUntypedso12.50equals12.5, a variable left out clearing its cell; values that are equal are not rewritten (a number keeps the digits it was stored with), which makes a replay write nothing; a change to a real participant refuses the request ("Real participants are read-only."), while a real participant's preset sent back unchanged is left alone, because the designer sends its whole list, read-only presets included; a variable that is no Property is ignored; participants left out of the list are untouched (nothing is deleted). Values are checked byPropertyValueRulesonly where they will be written, so an unchanged value stored before a rule changed is kept, also when another value of that participant changes. A variables object that repeats a name is a 400. Writes go throughCreatePersonCommand(IsTest) andUpdatePersonCommand, so their locks, races and rules apply; they stop at the first failure, and a replay finishes the rest. The reply's presets are the first 20 participants (Test participants first) plus every one the request named, so a preset saved past the first 20 is in it.AudienceValueJson.Untypedturns a survey value into cell text: null is empty, a string its text, a number one form (R, so1.50and"1.5"become1.5), anything else compact JSON with non-ASCII text kept (an emoji is still escaped).Untyped(t, Typed(t, v)) == vfor a value in that form, andTyped(t, Untyped(t, x)) == Typed(t, x)always.Two personalised create paths. On behalf (
source: on_behalf) is for real responses entered by a trusted caller: it resolves and binds a Submitter and counts against one-per-person. Test as this person (source: test) is for survey builders and does neither. The snapshot shape is the same, since both read cells throughSnapshotCells;sourceon the submission'spersonalizationread tells them apart.Links. The token is stored as a SHA-256 hash. A claimed link resumes without a lock. A first open checks
LinkOpenGatesbefore the lock: a disabled form or a removed person is 404 at once, and a link open never writes either. It then takesITransactionLock(scopeAudienceLinkOpen, key the link id, 5 s wait, then 503) throughLinkOpenLock, and then its form's sharedFormSubmissionsLock(no wait: 503 while the form is being deleted), on one connection:AppDbContextjoins the audience transaction. Under the lock it re-reads the claim, adopts a submission an earlier open left (FormId+AudienceLinkId) or inserts one, issues the token, and claims with one update (SubmissionIdstill null). All of that commits together. A claim lost to a writer outside the lock rolls back and resumes that writer's submission. A link claimed before tokens were issued this way gets its first token under the lock. A person removed before that claim is not found, and the link stays unopened: every path under the lock (insert or take-over) reads the snapshot, which reads the cells before the membership, and a removal deletes both in one save. A person removed after open is 404 on redeem too, and their access token is refused (see the redeem route above).One response per person. On a one-response form the open resolves the person's Submitter before the lock:
Member.SubmitterIdwhen bound, elseIntegration+ the identifier (what on-behalf resolves), created if missing and bound once. The link's submission carries it with the single-response gate, soUX_Submissions_RestrictionKeyrefuses a second one from either path. A refused insert rolls back, and the retry takes the other writer's submission over. A create stores its token in its insert, so a take-over's token is never replaced by a later save. For email tenants the identifier is matched as first entered, so an on-behalf call with another casing is a different Submitter untilMember.SubmitterIdis bound. See ARCHITECTURE.md → Transaction locks.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Ardalis.GuardClauses (>= 5.0.0)
- CsvHelper (>= 33.1.0)
- Endatix.Api (>= 0.9.0)
- Endatix.Core (>= 0.9.0)
- Endatix.Framework (>= 0.9.0)
- Endatix.Infrastructure (>= 0.9.0)
- Endatix.Modules.Audience.Contracts (>= 0.9.0)
- FastEndpoints (>= 7.1.1)
- MediatR (>= 12.4.1)
- Microsoft.EntityFrameworkCore (>= 10.0.0)
- Microsoft.EntityFrameworkCore.SqlServer (>= 10.0.0)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Endatix.Modules.Audience:
| Package | Downloads |
|---|---|
|
Endatix.Hosting
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.