feca-mailshots-plugin/requirements/mailshot_data_source.md

30 KiB

Mailshot Data Source page

Purpose

Document requirements for a safe, non-admin-friendly mailshot data source definition model.

Environment baseline for this specification

members as a database name means the value of MEMBERS_REMOTE_MYSQL_DB. fen as a database name means the value of FEN_REMOTE_MYSQL_DB.

For this specification:

  • Data-source definition persistence (create/update/delete/list of mailshot_queries) uses MAILSHOTS_REMOTE_MYSQL_DB.
  • Source-data query execution for membership built-in/custom recipient sources uses MEMBERS_REMOTE_MYSQL_DB.
  • Source-data query execution for FEN editorial, advertising, and invoice built-in sources uses FEN_REMOTE_MYSQL_DB.

Problem with prior approach

Creating a new data source currently requires:

  • knowledge of MySQL schema details, or
  • admin-level access to create DB views/queries.

This blocks non-expert and non-admin users.

Direction

Use a strict, constrained query language (DSL) for mailshot audience selection.

The DSL text is stored as the data-source definition in MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries, then parsed and compiled into safe SQL executed against approved source-data tables in MEMBERS_REMOTE_MYSQL_DB and FEN_REMOTE_MYSQL_DB.

Goals

  • Allow non-admin users to define useful audiences.
  • Prevent arbitrary SQL.
  • Keep execution efficient (push filtering to SQL, avoid large unnecessary joins).
  • Make queries explainable and previewable.

Example requirements (in natural language)

  • All contacts with name like "smith".
  • All contacts joined to member accounts where contact FEN1 is true.
  • All renewal rows marked as selected.
  • All pending renewals.
  • All accounts where name is not in an exclusion table.

Strict grammar (v1.6)

Use lowercase keywords only.

EBNF

query              = source_expr , [ where_clause ] ;

source_expr        = source_term , { source_op , source_term } ;
source_term        = source_ref | "(" , source_expr , ")" ;
source_op          = "and" ;

source_ref         = built_in_source | table_ref ;
built_in_source    = "contacts"
                   | "accounts"
                   | "renewals"
                   | "advertisers"
                   | "ads"
                   | "pages"
                   | "articles"
                   | "issues"
                   | "invoices" ;
table_ref          = ident_part , "." , ident_part ;
ident_part         = identifier | quoted_identifier ;

where_clause       = "where" , predicate_expr ;
predicate_expr     = predicate_term , { bool_op , predicate_term } ;
predicate_term     = [ "not" ] , predicate_atom
                   | "(" , predicate_expr , ")" ;
bool_op            = "and" ;

predicate_atom     = filter_expr
                   | field_ref , compare_op , value
                   | field_ref , ("=" | "!=") , field_ref
                   | field_ref , [ "not" ] , "in" , "(" , field_ref , ")"
                   | field_ref , "in" , "(" , value_list , ")" ;

filter_expr        = filter_name
                   | filter_name , "(" , value_list , ")" ;

filter_name        = "selected-renewal"
                   | "pending-renewal"
                   | "selected"
                   | "issue"
                   | "pending-invoice"
                   | "selected-invoice"
                   | "invoice-ids"
                   | "fen1-contact"
                   | "primary-contact"
                   | "member-or-affiliate-or-parish-council" ;

field_ref          = source_ref , "." , field_name ;
field_name         = identifier | quoted_identifier ;
compare_op         = "=" | "!=" | "contains" | "starts-with" | "ends-with" ;

value_list         = value , { "," , value } ;
value              = quoted_string | number | "true" | "false" ;

identifier         = letter , { letter | digit | "_" } ;
quoted_identifier  = "`" , { character - "`" } , "`" ;
quoted_string      = "'" , { character - "'" } , "'" ;
number             = digit , { digit } ;
letter             = "a""z" | "A""Z" ;
digit              = "0""9" ;

Semantics (v1.6)

  • built_in_source maps to a predefined table alias.
  • For accounts, the compiler must implicitly left-join account picklists so additional readable virtual fields are available:
    • accounts.type from picklist_account_type.value
    • accounts.public_location from picklist_public_location.value
    • accounts.account_sector from picklist_sector.value
  • table_ref maps directly to a table in the remote members database (schema.table).
  • For table_ref, validation must confirm the table exists.
  • Query result shape must be source-faithful and deterministic per DSL:
    • each cited source contributes its full allowed field set to an expected-field contract;
    • preview and template-token resolution must preserve availability of every field in that contract, even when raw SQL row keys are sparse;
    • source-native columns from query output may also be present;
    • no unrelated synthetic recipient projection columns are allowed.
  • If the resolved source does not expose an email field, validation must return a warning:
    • Data Source does not have an email field, it cannot be used for a Mailshot
    • The sentence is still valid and can be saved and previewed.
  • and between sources means relational intersection using configured join paths (not email-only matching).
  • Built-in accounts and contacts queries must automatically exclude soft-deleted rows when the source exposes is_deleted; values other than 0 are treated as deleted. The generated predicate must avoid collated string comparison so text-backed flags with different collations do not fail at runtime.
  • For custom sources, an explicit field equality predicate can provide join semantics.
  • where applies after source composition.
  • not negates only the next predicate/group.
  • Predefined predicates are selected-renewal, pending-renewal, selected, issue, pending-invoice, selected-invoice, invoice-ids, fen1-contact, primary-contact, member-or-affiliate-or-parish-council, and account-has-article-in-issue.
  • renewals is a built-in source mapped to membership renewal rows.
  • pending-renewal applies only when source set includes renewals and means renewals.status = 'pending'.
  • selected-renewal applies only when source set includes renewals and means renewals.selected = true.
  • primary-contact applies only when source set includes contacts and means contacts.is_contact_1 is truthy.
  • FEN editorial, advertising, and invoice data must be exposed as built-in sources, not custom table references, using the source names below.
  • These built-in sources are derived from the sibling ../feca2-app/server/src/lib/mailshotDsl.js implementation, except that articles must now be promoted to a first-class built-in source. In the sibling implementation, Articles is used by an issue-scoped article filter but is not listed as a standalone built-in source.
  • FEN built-in source table mappings:
    • advertisers maps to fen.advertisers.
    • ads maps to fen.ads.
    • pages maps to fen.Pages.
    • articles maps to fen.Articles.
    • issues maps to fen.Issues.
    • invoices maps to fen.invoices.
  • Required FEN source fields and canonical token aliases:
    • advertisers: name / advertisername from AdvertiserName.
    • ads: id from ID, advertiser from Advertiser, adsize / size from AdSize, price from Price, issue from the related page Issue, pageid from PageID, state from State, notes from Notes.
    • pages: id from ID, issue from Issue, page from Page.
    • articles: id from ID, pageid from PageID, articlenumber / article from ArticleNumber, content from Content, membername from MemberName, author from Author, dcn from DCN, articlewords from ArticleWords, otherwords from OtherWords, othercontent / other from OtherContent.
    • issues: id / issue from ID, issuemonths from IssueMonths, description from Description.
    • invoices: id, issue / issue_id, ad_id, invoice_number, invoice_date, due_date, invoice_page, invoice_size, invoice_price, status, payment_date, amount_paid, payment_method, payment_reference, notes, created_at, updated_at.
  • Required FEN physical columns:
    • fen.Issues: ID, CopyDate, PublicationDate, IssueMonths, Description.
    • fen.Pages: ID, Issue, Page, PageSizeName, Content.
    • fen.Articles: ID, PageID, ArticleNumber, Content, MemberName, Author, DCN, ArticleWords, OtherWords, OtherContent.
    • fen.ads: ID, PageID, AdSize, Advertiser, Price, State, Notes.
    • fen.advertisers: Entry ID, AdvertiserName, title, contact_name, address_1, address_2, town, post_code, Description, IsLapsed?, Home Phone, Phone, Email, Selected.
    • fen.invoices: id, issue_id, ad_id, invoice_number, invoice_date, due_date, invoice_page, invoice_size, invoice_price, status, payment_date, amount_paid, payment_method, payment_reference, notes, created_at, updated_at.
  • selected applies only when source set includes advertisers and means advertisers.Selected is truthy.
  • issue(<issue>) accepts exactly one numeric issue ID. It applies when the source set includes one of advertisers, ads, pages, articles, issues, or invoices, and means the row is associated with that issue. The compiler may traverse hidden approved paths to apply the filter, but only explicitly cited sources contribute fields to the result/template context.
  • When issues is explicitly appended to an otherwise joined source set and a non-negated issue(<issue>) filter is present, issues may be attached as a one-row issue context source even when there is no direct approved join path from the preceding source. Without that constraining issue filter, the join must remain invalid.
  • pending-invoice applies only when source set includes invoices and means invoices.status = 'pending'.
  • selected-invoice applies only when source set includes invoices and means the invoice ID is in the runtime selected invoice ID list.
  • invoice-ids(...) applies only when source set includes invoices; it accepts one or more numeric invoice IDs.
  • member-or-affiliate-or-parish-council applies to account data and means:
    • accounts.type is Member or Affiliate, or
    • accounts.name contains Parish Council.
  • account-has-article-in-issue(<issue>) applies only when source set includes accounts; it accepts exactly one numeric issue ID and means an article in that issue has Articles.MemberName matching accounts.name. Use not account-has-article-in-issue(<issue>) to exclude accounts that already have an article in the issue without adding article fields to the result.
  • Text comparisons that bridge FEN and member data, including article/member and advertiser/ad name matching, must normalize both sides to the same UTF-8 collation to avoid runtime collation errors.
  • field_ref is restricted by whitelist per built-in source.
  • field_ref is also supported for selected custom sources when table metadata is available.
  • Field-to-field comparisons are supported with = and != only.
  • Set-membership against another field source is supported via:
    • field_ref in (field_ref)
    • field_ref not in (field_ref)
  • For in(field_ref) / not in(field_ref), the RHS source is treated as a reference source (subquery semantics), not a joined source.
  • contains, starts-with, and ends-with require a literal right-hand side value (not a field reference).
  • In v1.6, mixing built-in sources and custom sources in the same sentence is not supported; validation must fail with a clear error.
  • For multi-custom-source sentences, all selected custom sources must be connected by explicit = field-to-field predicates (graph-connected join semantics), otherwise validation fails.
  • in(field_ref) / not in(field_ref) must compile as subquery membership; the RHS source is a reference source and does not need to appear in source_expr.
  • Canonical naming for built-in sources uses the token aliases listed in this document.
  • Canonical naming for custom table references uses exact field names as defined in database metadata for the selected source. The implementation must reject invalid custom field names and must not add aliasing/normalization fallback.

Join-path model (required)

The compiler must use an explicit join graph per source pair. Example v1 join paths:

  • contacts -> accounts: contacts.Accountid = accounts.ID
  • accounts -> contacts: accounts.ID = contacts.Accountid
  • renewals -> accounts: renewals.account_id = accounts.ID
  • accounts -> renewals: accounts.ID = renewals.account_id
  • renewals -> contacts: renewals.account_id = contacts.Accountid
  • contacts -> renewals: contacts.Accountid = renewals.account_id
  • articles -> pages: articles.PageID = pages.ID
  • pages -> articles: pages.ID = articles.PageID
  • pages -> issues: pages.Issue = issues.ID
  • issues -> pages: issues.ID = pages.Issue
  • ads -> pages: ads.PageID = pages.ID
  • pages -> ads: pages.ID = ads.PageID
  • ads -> advertisers: normalized ads.Advertiser = advertisers.AdvertiserName
  • advertisers -> ads: normalized advertisers.AdvertiserName = ads.Advertiser
  • invoices -> ads: invoices.ad_id = ads.ID
  • ads -> invoices: ads.ID = invoices.ad_id
  • invoices -> issues: invoices.issue_id = issues.ID
  • issues -> invoices: issues.ID = invoices.issue_id
  • invoices -> pages: invoices.issue_id = pages.Issue
  • pages -> invoices: pages.Issue = invoices.issue_id

For built-in source lists, each newly-mentioned source may join to any earlier source in the same sentence, preferring the nearest earlier source that has an approved path. It is not restricted to the immediately preceding source. For example, invoices and ads and pages and advertisers where issue(125) is valid because advertisers can join back to the already-present ads source even though pages immediately precedes it.

If no approved join path exists from a newly-mentioned source to any earlier selected source for and, parsing/validation must fail with a clear error.

No implicit join behavior is allowed:

  • Selecting a source such as accounts must not silently join contacts (or any other source).
  • Output columns must come only from sources explicitly requested in the sentence.

Example valid queries

  • contacts
  • contacts and accounts where fen1-contact
  • contacts and accounts where primary-contact
  • accounts and contacts where contacts.Accountid = accounts.ID
  • contacts where contacts.Last contains 'smith'
  • contacts and accounts where accounts.Type = 'Member' and contacts.FENContact1 = true
  • accounts where member-or-affiliate-or-parish-council
  • renewals where pending-renewal
  • renewals where selected-renewal
  • renewals and accounts and contacts where pending-renewal
  • advertisers where selected
  • ads and pages where issue(202605)
  • articles and pages and issues where issue(202605)
  • invoices and ads and pages and advertisers where issue(125)
  • contacts and accounts and issues where fen1-contact and issue(202605)
  • contacts and accounts and issues where fen1-contact and issue(202605) and not account-has-article-in-issue(202605)
  • invoices where pending-invoice
  • invoices and ads and advertisers where invoice-ids(101, 102)
  • accounts where accounts.Name not in (members.ExcludedAccounts.ExcludedAccount)
  • members.mailshot_test

Example invalid queries

  • select * from members (raw SQL not allowed)
  • contacts where drop table (unknown tokens)
  • contacts where accounts.Name = 'x' (invalid if accounts is not included in source expression)
  • contacts or accounts (or is not supported; use and only)
  • accounts where pending-renewal (invalid: filter requires renewals source)
  • accounts where fen1-contact (invalid: filter requires contacts source)
  • accounts where primary-contact (invalid: filter requires contacts source)
  • ads where selected (invalid: filter requires advertisers source)
  • advertisers where pending-invoice (invalid: filter requires invoices source)
  • pages where issue (invalid: issue requires exactly one numeric issue ID)
  • accounts where issue(202605) (invalid: issue requires an issue-capable source)
  • invoices where invoice-ids('abc') (invalid: invoice-ids accepts numeric invoice IDs only)
  • renewals where member-or-affiliate-or-parish-council (invalid: filter requires accounts source)
  • accounts and contacts where contacts.Accountid contains accounts.ID (invalid: field-to-field supports only =/!=)
  • accounts where accounts.Name contains members.ExcludedAccounts.ExcludedAccount (invalid: contains requires a literal RHS)

Compilation and performance requirements

  • Parse DSL to AST.
  • Validate AST against allowed sources, fields, and operators.
  • Validate all and source combinations against approved join graph metadata.
  • Do not perform implicit source joins during compilation.
  • Compile AST to parameterized SQL only (no string-concatenated SQL).
  • Push filters into SQL WHERE / join conditions.
  • Preview must not assume fixed contact/account projection columns.
  • Compiler/runtime must enforce deterministic source-field availability from DSL-cited sources.
  • Preview column derivation must include:
    • all fields from the DSL expected-field contract, and
    • union of keys from returned rows (not just first row keys), to avoid hiding sparse/source-native columns.
  • For mailshot execution, email remains the only required field.
  • Provide preview endpoints: count + sample rows before execution.

Storage model

Store:

  • dsl_text (editable source).
  • dsl_text is the single source of truth for execution, preview, and validation.
  • No SQL fallback path is permitted when dsl_text is missing/empty; this must be treated as an error.

Optional:

  • normalized AST JSON for diagnostics/explain.
  • sql retained as blank/deprecated for backward compatibility when the legacy column exists, and never executed.

User interface requirements

Provide a UI to view, create, update, validate, and preview DSL sentences.

Mailshot data source page

Add a dedicated page "Data Sources" under FECA Mailshots admin page for managing data source sentences.

Top regions:

  • Context pane:
    • page title Mailshot Data Sources
  • Information pane:
    • validation errors, save status, preview status
  • Statistics pane:
    • total saved data sources
    • selected data source id/name

Data source list (view)

Provide a table/list showing existing data sources with columns:

  • ID
  • Name
  • DSL sentence
  • Updated at

List behavior:

  • single-row selection
  • text filter by Name or sentence content
  • sort by Name and Updated at

Selection to persist between page loads.

Editor (create/update sentence)

Provide an editor panel with:

  • Name input (required)
  • multi-line DSL sentence input (required)
  • read-only Last validated status

Actions:

  • New:
    • clear editor and start create mode
  • Save:
    • create or update current record
    • enabled only when form is valid and has unsaved changes
  • Discard changes:
    • revert editor to last saved state
  • Delete:
    • confirmation required
    • blocked when the data source is referenced by one or more mailshots

Validation UX

Provide explicit validation before save and on-demand:

  • Validate sentence button
  • parse + semantic validation against:
    • grammar
    • source/field/operator whitelist
    • join-path rules

On validation result:

  • show Valid / Invalid
  • show Warnings separately from errors
  • for invalid:
    • show user-friendly error message
    • include line/column when available
    • do not save invalid sentence
  • for warning-only cases (for example missing email field):
    • allow save
    • allow preview
    • indicate the data source cannot be used for sending a mailshot

Preview UX

Provide Preview recipients action for selected sentence:

  • show recipient count
  • show sample rows (for example first 50)
  • derive displayed columns from the deterministic expected-field contract plus returned row shape
  • do not assume fixed contact/account projection columns
  • show execution time
  • do not execute/send mailshot from this page

DSL Sentence Builder (required)

Provide a Build DSL action in the data-source editor.

On click, open a modal/overlay builder that allows users to construct full valid DSL without memorising grammar keywords, filter names, or field names.

Builder layout:

  • Top sub-pane: Data Sources
  • Bottom sub-pane: Constraints / Filters
  • Footer: generated DSL preview + actions

Top sub-pane: Data Sources

Provide source selection controls for:

  • contacts
  • accounts
  • renewals
  • custom table source (schema/table selector or validated text input)

Behavior:

  • multiple source selection is combined using and
  • order of selected sources is preserved in generated sentence
  • selecting an invalid source combination is blocked in UI with clear message

Bottom sub-pane: Constraints / Filters

Provide controls to add one or more constraint rows.

Each row supports one of:

  • predefined filter
  • field comparison
  • grouped expression with not

Predefined filter control:

  • dropdown label uses user-friendly text, not raw DSL token
  • examples:
    • Renewal is selected -> selected-renewal
    • Renewal is pending -> pending-renewal
    • Advertiser is selected -> selected
    • Issue is -> issue(...)
    • Invoice is pending -> pending-invoice
    • Invoice is selected -> selected-invoice
    • Invoice ID is one of -> invoice-ids(...)
    • Contact is FEN1 -> fen1-contact
    • Contact is primary -> primary-contact
    • Account is member/affiliate/parish council -> member-or-affiliate-or-parish-council

Field comparison row:

  • source dropdown (restricted to selected sources)
  • field dropdown (restricted to fields allowed for selected source)
  • operator dropdown (=, !=, contains, starts-with, ends-with, in)
  • RHS mode dropdown:
    • literal value input (typed by expected value type), or
    • source+field selector for field-to-field comparisons and set-membership (in)
  • for in, provide multi-value token input

Logical composition:

  • all rows combine with and in v1.6
  • each row can be negated via checkbox (not)
  • optional group rows allow nested bracketed expressions

Generated DSL + Sync

Footer must show:

  • read-only generated DSL sentence (live update)
  • validation status
  • parse/semantic errors with line/column mapped to relevant builder controls

Actions:

  • Apply writes generated DSL to the editor text area
  • Cancel closes overlay with no change
  • Reset clears builder selections

Sync rules:

  • opening builder from existing DSL pre-populates controls when sentence is parseable
  • if existing DSL is not parseable, show message and allow user to start from clean builder
  • direct text edits remain supported; builder is not the only editing path

Safety and usability requirements

  • UI must prevent creating syntactically invalid DSL states
  • UI must prevent selecting filters incompatible with current source set
  • custom table sources must be validated before Apply
  • generated DSL must always conform to strict grammar in this spec

API expectations for UI

The UI expects backend endpoints for:

  • list data sources
  • get one data source
  • validate sentence
  • create data source
  • update data source
  • delete data source
  • preview sentence (count + sample)
  • list schemas for custom source selection
  • list tables for selected schema
  • list fields for selected source (built-in or custom)

WordPress REST route contract:

  • GET /wp-json/mailshots/v1/data-sources
  • GET /wp-json/mailshots/v1/data-sources/{id}
  • POST /wp-json/mailshots/v1/data-sources/validate
  • POST /wp-json/mailshots/v1/data-sources
  • PUT /wp-json/mailshots/v1/data-sources/{id}
  • DELETE /wp-json/mailshots/v1/data-sources/{id}
  • POST /wp-json/mailshots/v1/data-sources/preview
  • POST /wp-json/mailshots/v1/data-sources/{id}/preview
  • GET /wp-json/mailshots/v1/data-sources/schemas
  • GET /wp-json/mailshots/v1/data-sources/tables?schema=...
  • GET /wp-json/mailshots/v1/data-sources/source-fields?source=...

Auth/capability requirements:

  • all routes require authenticated WordPress users;
  • in this plugin, editor-capable operational access is the baseline:
    • create/update/delete/read/preview/validate all require WordPress edit_pages (editor or administrator);
  • plugin Setup/settings remains administrator-only under WordPress manage_options (defined in top-level policy).

All endpoints must enforce server-side validation even if client validates first.

Additional functional requirements (in scope)

  • Mailshot Data Source preview, Mailshot Recipients Preview, and Mailshot Test preview must all display source-faithful columns using the same result-shape rule.
  • DSL Builder must support custom sources via schema + table selection (not text-only entry), with manual text fallback only when metadata APIs are unavailable.
  • After a custom source is added in Builder, it must be immediately available in:
    • source dropdowns,
    • LHS field dropdowns,
    • RHS field dropdowns (for field comparisons).
  • Builder must support field not in (other_source.other_field) generation using RHS source + field controls (not value-type literal controls).
  • Builder prefill must parse and restore field references where source names are qualified (for example members.ExcludedAccounts.ExcludedAccount).
  • Validation errors must be explicit for unsupported/ineffective predicates; no silent no-op predicate behavior.

Implementation details

This section is normative guidance for regenerating the feature from scratch.

1. Data model

  • Table: MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries
  • Required columns:
    • ID (PK)
    • name
    • dsl_text
    • updated_at (recommended)
  • dsl_text is the only executable representation.
  • Persist sql as blank/deprecated if column still exists for backward compatibility; do not execute it.

2. Backend architecture

Build a strict pipeline:

  1. Tokenize DSL text.
  2. Parse to AST.
  3. Validate AST (grammar + semantics).
  4. Compile AST to parameterized SQL and parameters.
  5. Execute SQL for preview/send paths.

Keep parser/validator/compiler in a dedicated module (for example mailshotDsl.js), and route handlers thin.

3. Parser/AST contract

  • AST should separate source_expr and where.
  • Predicates should distinguish:
    • named filters,
    • compare literal,
    • compare field-to-field,
    • in(value_list),
    • in(field_ref) / not in(field_ref).
  • Preserve token line/column for diagnostics.

4. Validation rules (server-side)

  • Validate source compatibility via explicit join-path metadata.
  • Validate filter applicability against selected sources.
  • Validate field refs against per-source field whitelist:
    • built-ins: static map
    • custom: metadata-provider abstraction (not direct SQL dependency in validator logic)
  • Enforce:
    • field-to-field only = / !=
    • literal RHS only for contains / starts-with / ends-with
  • Return warnings (not errors) for no-email-source sentences.
  • Metadata-provider minimum DB grants must be documented and kept minimal for the read-only DB user.

5. SQL compilation strategy

Use parameterized SQL only.

  • Built-in-only source expressions:
    • compile from fixed source metadata (table + alias + base where).
    • compile approved joins only.
  • Custom-only source expressions:
    • single source: SELECT * FROM schema.table WHERE ...
    • multi source: require explicit = field join connectivity and compile with aliased joins.
  • in(field_ref) / not in(field_ref):
    • compile as EXISTS / NOT EXISTS subqueries.

6. Preview/result-shape rules

  • Normalize row values to JSON-safe scalars.
  • Enforce expected-field contract from DSL-cited sources.
  • Keep source-native returned columns available.
  • It is allowed to expose normalized source-field aliases required to satisfy the expected-field contract.
  • Do not add unrelated synthetic recipient projection columns.
  • Derive display columns from expected-field contract plus union of keys in returned rows.
  • Prune-all-empty columns only if explicitly configured and documented; default should preserve source-faithful shape.

7. API surface

Implement at minimum:

  • GET /wp-json/mailshots/v1/data-sources
  • GET /wp-json/mailshots/v1/data-sources/{id}
  • POST /wp-json/mailshots/v1/data-sources/validate
  • POST /wp-json/mailshots/v1/data-sources
  • PUT /wp-json/mailshots/v1/data-sources/{id}
  • DELETE /wp-json/mailshots/v1/data-sources/{id}
  • POST /wp-json/mailshots/v1/data-sources/preview
  • POST /wp-json/mailshots/v1/data-sources/{id}/preview
  • GET /wp-json/mailshots/v1/data-sources/schemas
  • GET /wp-json/mailshots/v1/data-sources/tables?schema=...
  • GET /wp-json/mailshots/v1/data-sources/source-fields?source=...

All endpoints must validate server-side and return structured errors: { ok:false, error, line?, column? }.

8. UI generation guidance

Mailshot Data Source page:

  • left: data source list with select/filter/sort.
  • right: editor + validation + preview.
  • include Build DSL modal.

Builder modal:

  • source selection section (built-in + schema/table custom add).
  • constraints section with row editor (filter/comparison).
  • responsive row layout that wraps controls on narrow widths.
  • RHS field mode must expose source + field selectors.
  • show generated DSL live; validate with debounce.
  • Apply writes DSL text; Cancel leaves editor unchanged.

9. Safety and regression test guidance

Minimum automated tests:

  • parser positive/negative cases (line/column assertions).
  • semantic validation failures (join path, filter applicability, field whitelist).
  • compile output shape for:
    • built-in joins,
    • custom joins,
    • in(field_ref) and not in(field_ref).
  • preview shape:
    • includes all expected fields from cited sources, and
    • does not introduce unrelated synthetic recipient projection columns.
  • builder prefill for qualified source field refs.
  • no-op predicate prevention (must error, not silently ignore).