workspace.matters.ai/sales/export turns the nine fragmented lead stores into a single
export surface. Any combination of sources goes into one file, columns are chosen
either by hand or via a canonical cross-source schema, and a live preview shows the
exact rows before anything is downloaded.
The console is one configuration object driving two outcomes — preview and download — so the preview cannot drift from the file: both call the same endpoint and the same engine.
The previous tool exported one collection at a time, with a fixed six-item field list that
did not match any real document, and a withPII boolean the client chose for itself. The
rewrite fixes all three:
| Before | Now |
|---|---|
One collection per run | sources: string[] — any combination, merged or per-source |
Hardcoded field list (email, phone, fullName, …) | Fields discovered from the live documents, with per-source presence |
withPII: true honoured from the request body | PII resolved from org:observability:pii_view clearance; an uncleared request is downgraded to masking |
| CSV only, no injection guard | CSV / XLSX / JSON / NDJSON; formula injection neutralised |
| No preview | Live preview running the real pipeline |
| No reuse | Saved presets + audit-backed history |
apps/workspace/app/(workspace)/sales/export/
page.tsx console shell (client)
components/
useExportConsole.ts all state + fetch orchestration
primitives.tsx themed Panel / CheckRow / SegmentedControl / inputs
SourcePanel.tsx multi-select sources + live row counts
FilterPanel.tsx date presets, range, status, search
FieldPanel.tsx column strategy + custom picker (ordered)
OutputPanel.tsx layout, format, PII, row options
PreviewPanel.tsx stats, warnings, the actual first N rows
PresetsPanel.tsx presets + history
apps/workspace/app/api/workspace/sales/export/
route.ts POST — preview or download
schema/route.ts GET — field discovery
presets/route.ts GET / POST
presets/[id]/route.ts PATCH / DELETE
history/route.ts GET — recent runs from the audit trail
lib/services/sales/
export.types.ts shared contract (client + server)
export.service.ts the engine
export-request.schema.ts zod contract + legacy-body normaliser
export-schema.service.ts field discovery
export-serializer.ts result → bytes
export-xlsx.ts workbook writer (exceljs, dynamically imported)
export-presets.service.ts saved recipes
export-history.service.ts audit-trail reader
lead-field-map.ts canonical schema + alias table
lead-normalize.ts flatten / canonicalise / PII policy
lead-sampler.ts cached per-collection schema sampling
lead-collections.ts the source registry (unchanged)
Lead stores grew independently. A demo request calls it full_name, a webinar registration
name, a careers application firstName + lastName. Union those raw and a merged CSV
gets three half-empty name columns.
lead-field-map.ts declares one
canonical vocabulary and, per field, the aliases that feed it in priority order:
{
key: "email",
label: "Email",
aliases: ["email", "email_address", "emailAddress", "work_email", ...],
pii: true,
mask: "email",
searchable: true,
type: "string",
}
Resolution rules (see toCanonicalRow):
leadJourney.firstTouch.utm_campaign resolves campaign.name is composed from firstName + lastName when no source carries a full name.createdAt is coerced to an ISO instant regardless of how the source stored it
(Date, epoch number, or string).null — callers can tell
"absent" from "empty".Warning
Adding a canonical field is safe. Adding an alias changes what an existing export column contains for every consumer downstream — treat that as a data-contract change.
GET /api/workspace/sales/export/schema?sources=a,b,c
For each selected source, lead-sampler.ts
reads the newest 150 documents via the always-indexed _id sort, flattens them to dot
paths, and records per-key presence, observed types, and a first non-empty sample. Results
are cached for 5 minutes so picker interactions do not re-scan.
export-schema.service.ts then
merges those samples into one catalogue:
sources: [{ id, presence }] so the picker can show 3/5 src · 92%
and an operator can spot a column that will be mostly empty before exporting it;org:observability:pii_view — the picker must never become a PII side-channel.A source whose database is unreachable comes back with error set (never cached) so the
UI greys it out instead of failing the whole request.
| Mode | Columns | Use when |
|---|---|---|
canonical | The cross-source vocabulary | Combining sources — the only mode where a header means the same thing on every row |
all | Every raw key, verbatim per source | A one-off audit of a single source |
custom | Exactly what was ticked, in the order ticked | A CRM import that needs a specific column order |
canonical and custom read from a canonical overlay (canonical values win where they
resolved), so a picked email column is populated whether the source spells it email,
work_email, or contact_email. all stays strictly raw so "everything this source
stores" means exactly that.
options.excludeFields is applied after the strategy — the escape hatch for "everything
except these three noisy keys".
The most common ask — "email and name always, then a few more columns, across most of the sources":
Email, Full name, then Company, Source,
Asset / event, Created at, or whatever else you need. The order you
tick them is the column order, and the ↑/↓ arrows on the selected list
change it.Mask or Include. ← This is the step people
miss.Warning
Email and Full name are PII, and the default policy is omit — which
deletes them. Tick email, name, company while personal data is set to
omit and you get a file containing only company. Nothing is broken; the
policy is doing exactly what it says. But an explicitly picked column
vanishing is data loss, so since 2026-09-07 the picker shows an inline
warning naming the affected columns with one-click Mask them instead /
Include raw values buttons, the PII chip reads "PII — omitted" rather than
just "PII", and the run emits a warning in the preview and in the download
summary. include still needs org:observability:pii_view; mask does not
and keeps rows distinguishable (es****@matters.ai, E. T.).
Verified live on 2026-09-07 with those seven sources — header row:
Data source,Data source name,Full name,Email,Company,Source,Asset / event,Created at
whitepaper_leads reports "no rows matched" because that database has no
collection yet; the other six deliver, and cross-source dedupe on email is
significant (40 of 85 rows collapsed in that run).
| Layout | CSV | XLSX | JSON | NDJSON |
|---|---|---|---|---|
merged | one table, _source + _sourceLabel columns (headed "Data source" / "Data source name", deliberately not "Source" — the canonical source field already owns that label, and duplicate headers break importers that map columns by name) | one Combined sheet | { manifest, data: [...] } | one object per line |
per_source | sectioned file: # Label (id) — N rows then a header row per source | one sheet per source | { manifest, data: { [sourceId]: rows } } | one object per line (_source distinguishes them) |
A CSV is a single table, so per_source CSV can only be a sectioned file. XLSX is the
format that expresses per-source separation natively, and the console says so inline rather
than letting someone discover it after the download.
Per source, buildQuery:
$or across every timestamp alias the collection actually carries
(discovered by sampling), and for each, across both the Date and the ISO-string
encoding, because older records in these stores were written as strings. Boundaries are
YYYY-MM-DD resolved at UTC day edges, so the same range means the same rows
regardless of who runs it from where. A source with no timestamp field raises a warning
rather than silently returning everything.$or across the status aliases present.The DB sorts on _id — time-ordered and always indexed. Sorting on a timestamp alias would
table-scan, because none of the six spellings is reliably indexed across nine collections.
The merge step then re-sorts the (already bounded) result set on the canonical createdAt;
rows with no resolvable date sort last so they never displace real data at the top.
Three modes, and the request only asks:
| Mode | Effect |
|---|---|
omit | PII columns are dropped entirely (the default) |
mask | Columns kept, values masked: ad****@acme.com, ********3210, A. L. |
include | Raw values — requires org:observability:pii_view |
resolvePiiMode(requested, cleared) downgrades an uncleared include to mask, not to
omit: the operator explicitly wanted that column, and a masked column still supports
counting and eyeballing without disclosing an identity. The downgrade is surfaced in the
preview warnings and in stats.piiDowngraded.
Secrets are not PII and are never exportable at any clearance. isSecretKey drops
password, token, api_key, signature, otp, hash, salt, session_id and
unsubscribe* at flatten time, before a column can exist.
| Option | Effect |
|---|---|
dedupe: "email" | One row per canonical email across all sources; rows with no email are always kept (no identity to dedupe on) |
dedupe: "email_per_source" | Deduplicates within each source only |
requireEmail | Drops rows with no resolvable email |
includeSourceColumn | Adds _source + _sourceLabel (default on) |
includeRowId | Keeps the database _id for joins and re-imports |
includeManifest | Adds the run manifest (JSON key / XLSX sheet) |
bom | UTF-8 BOM so Excel renders accented names correctly (CSV) |
delimiter | , ; TAB | (CSV) |
limitPerSource | 500 – 25,000 |
sort | newest / oldest on the canonical timestamp |
| Constant | Value | Why |
|---|---|---|
MAX_ROWS_PER_SOURCE | 25,000 | ceiling a caller can request per source |
MAX_TOTAL_ROWS | 50,000 | ceiling across all sources in one artifact |
DEFAULT_ROWS_PER_SOURCE | 5,000 | |
PREVIEW_FETCH_CAP | 500 | rows actually fetched per source during a preview |
MAX_COLUMNS | 300 | guards all mode against an unreadable spreadsheet |
SAMPLE_SIZE | 150 | documents inspected per collection for discovery |
Truncation is always reported — per source (stats.sources[].truncated) and overall
(stats.truncated), plus a human-readable warning.
POST /api/workspace/sales/export with preview: true runs the same pipeline with the
smaller fetch cap and returns:
{
success: true,
stats: ExportStats, // per-source matched/exported, dedupe, PII, truncation
columns: ExportColumn[], // resolved columns, in export order, PII-flagged
rows: Record<string, unknown>[],
estimatedBytes: number, // projected from the sample
warnings: string[],
}
stats.matched is exact (a real countDocuments). stats.totalExported and
duplicatesRemoved are projections from the capped sample, flagged by stats.estimated
and labelled "(est.)" in the UI. The console auto-refreshes the preview 700 ms after any
configuration change while Live is on, aborting the in-flight request when the operator
keeps typing.
A preview that returns unmasked PII is a disclosure in its own right, so it is written to the audit trail even though no file left the building. Non-PII previews are not audited — otherwise the history panel would drown in keystrokes.
Every download is kept server-side so it can be taken again without re-running the query — which matters because re-running is not equivalent: the data moves, so a second run produces a different file. The console's Recent exports panel has two tabs: Saved (artifacts still on disk, one click to re-download) and Activity (the audit trail, which outlives the files).
GridFS, in the same cluster as the source records — bucket sales_exports
(sales_exports.files + sales_exports.chunks in the mattersai database).
These artifacts are concentrated lead PII. A download has to be Clerk- and RBAC-gated whatever backs it, so a blob store or CDN buys nothing here: every byte still flows through an authenticated route. What it would add is a second system holding the same personal data, with its own credentials, retention policy and access log. Keeping artifacts in the cluster that already stores the source records adds no new data-residency surface, needs no provisioning to work, and lets retention use the same primitives as the rest of the module.
canDownloadArchived(pii, viewerPiiCleared) re-checks the downloader against
the stored run's PII mode. A raw-PII artifact stays gated on the downloader
holding org:observability:pii_view — otherwise re-downloading someone else's
export would be a clean route around the check the generating run enforced. The
list still shows the row (with downloadable: false and a lock icon) so the
existence of the file is not itself a secret.
Re-downloads are audited exactly like original runs, with redownload: true
and originallyBy — the point of the trail is who obtained the data, not who
first generated it.
| Rule | Value | Why |
|---|---|---|
| Standard window | RETENTION_DAYS = 7 days | long enough to cover "resend me Monday's file" |
| Raw-PII window | RETENTION_DAYS_WITH_PII = 1 day | the convenience is measured in hours, the liability in weeks |
| Per operator | MAX_ARCHIVED_PER_USER = 10 | a shortcut list, not an archive |
| Size cap | MAX_ARCHIVE_BYTES = 25 MB | the operator still gets the file, it just is not kept |
Expiry is enforced twice: pruneArchive deletes past-window artifacts (and
trims the owner back to the retained count) on every archive write, and
listArchivedExports filters on expiry as well — so retention holds even if
nobody has run an export since the window closed.
Warning
Never put a TTL index on sales_exports.files. A TTL index deletes the
metadata document and orphans its chunks forever, silently growing the
collection with unreachable data. GridFS retention has to go through
bucket.delete, which is why pruning is explicit code rather than an index.
Verified 2026-09-07: after a delete, both .files and .chunks are clean.
Archiving happens after the artifact is built and immediately before it is
returned, and archiveExport never throws. A storage failure is logged,
reported in the response summary as { archived: false, reason }, and recorded
in the audit metadata — but the operator still gets their download. An export
must never fail because the convenience copy could not be written.
| Route | Purpose |
|---|---|
GET …/export/archive?scope=mine|team | list live artifacts + the retention policy |
GET …/export/archive/[id] | stream one back (clearance re-checked, audited) |
DELETE …/export/archive/[id] | remove one — creator only |
An expired artifact returns 404, not 403: it should read as gone, not as forbidden.
/sales/leads reads through the same canonical map, for the same reason. It
used to read row.email literally, so every Demo Request showed a blank
email — 2,300+ leads, the biggest source — because that collection calls the
field workEmail. Searching them by address matched nothing either: the
service's hardcoded SEARCHABLE_FIELDS had the same blind spot. One missing
alias, two bugs, both invisible unless you knew the data.
getLeads now returns each row as the raw document with the canonical values
overlaid, so the table addresses row.email and gets a value whatever the
source calls it, while the detail panel still shows everything stored.
Necessarily — the table paginates at 25 rows, so sorting or filtering only the visible page would be a lie.
| Control | How it resolves |
|---|---|
| Search | $or across every alias of every searchable canonical field the collection actually has |
| Sort | the canonical key resolves to this collection's physical key (createdAt → timestamp on demo-requests), with _id as the tiebreak. Default stays _id desc, which is always indexed |
| Status | distinct() on the resolved status field populates the dropdown, so the options are the values that exist |
| Date window | inclusive UTC day edges, matched against both Date and ISO-string encodings |
An unsortable request falls back to _id rather than erroring, and the response
echoes the applied sort so the header shows the right arrow.
The API returns a per-collection catalogue and the viewer offers show/hide,
remembered per collection in localStorage. Two flags, because they are not the
same question:
available — the collection can produce a value. Note this is not "has a
physical key": name is composed from firstName + lastName when a source
stores no single name field, which is what demo-requests does. Keying
availability off the physical key alone hid the Name column on the biggest
source even though every row had a value.sortable — a single physical key backs it, so MongoDB can order by it. A
composed column renders as plain text with no sort control rather than a
button that silently does nothing.An uncleared viewer used to get a column of dashes, which made the page useless.
It now gets es****@matters.ai and E. T. — enough to tell two rows apart and
match a support ticket, not enough to identify anyone. This also closed a latent
hole: the old substring scrubber would have let a bare canonical name key
through, because "name" does not contain "firstname". The route uses
applyPiiPolicy, which classifies by canonical field.
The Leads Viewer's Export these button links to
/sales/export?sources=<id>&search=<q>, so "I want these as a file" is one click
rather than re-picking the source and retyping the search.
configFromSearchParams() seeds the console from the query once, on mount —
later edits belong to the operator, not the URL. Accepted params:
| Param | Effect |
|---|---|
sources | comma-separated collection ids; unknown ids are dropped (sending one would 400 the first preview) |
search | free-text filter |
from + to | YYYY-MM-DD; a window is adopted only when both are valid, otherwise the hand-off is treated as lifetime rather than silently applying the default 30-day range |
Stored in mattersai.sales_export_presets. A preset holds the request shape, never
rows — replaying it re-runs the query under the replayer's clearance, so a preset authored
with PII clearance still yields masked columns for someone without it.
shared: true grants read to everyone with export clearance; only the author can
edit or delete.preview / previewLimit describe one interaction and are stripped before saving.Schedules in the console: save the current configuration with a cadence and
recipients, and the export runs itself. Presets removed the re-picking; a
schedule removes the remembering.
Execution is a Vercel cron in the root app — /api/cron/sales-exports,
registered hourly in vercel.json. Hourly is the right tick because the finest
cadence a schedule can express is an hour-of-day, so every schedule fires inside
its own slot. Management lives in the workspace app
(/api/workspace/sales/export/schedules); only the records are managed there,
never the run.
A scheduled run never produces raw PII. There is no interactive user at
07:00, so honouring pii: "include" would mean persisting the creator's
clearance and replaying it — and it would keep working after they lose it. The
schema rejects include on write, the runner clamps it again for any schedule
that predates the rule, and the runner passes piiCleared: false so the engine
itself cannot be talked into it. Raw PII stays a live, attributable act.
The email never carries the file. Mail is not a confidential channel and an attachment leaves the org the moment someone forwards it. The run archives its artifact and the email links to the console's Saved tab, so the recipient authenticates and passes the same clearance check as any other download. The message carries no lead data at all — a name, a row count, a format, a link.
Recipients must be on the internal email allowlist
(ALLOWED_EMAIL_DOMAINS). A schedule that could mail a lead extract to an
arbitrary address is an exfiltration primitive with a cron attached. Every
rejected address is named in the error so the operator can fix them all at once.
Daily, weekly (with a weekday) or monthly (with a day of month), each at an
hour-of-day. computeNextRun computes everything with UTC accessors and the
UI says "UTC" rather than pretending otherwise — a schedule that drifted with the
viewer's timezone would fire at a different wall-clock hour depending on who last
opened the page, and would skip or double-fire across a DST boundary. The suite
runs under Australia/Adelaide (+09:30, observes DST) specifically to keep that
honest.
Day of month is capped at 28: a schedule set to the 31st would silently skip February.
| Concern | How |
|---|---|
| Double-runs | claimDueSchedule is an atomic findOneAndUpdate on claimedAt. Vercel can overlap ticks, and two ticks on one schedule would double-send and double-archive. |
| A crashed run | A claim older than 15 minutes is treated as abandoned and can be re-claimed. |
| A broken schedule | finalizeScheduleRun advances nextRunAt even on failure, so a failing schedule does not spin on every tick for a week. The error is stored and shown on the row. |
| Long ticks | 240s budget and 10 schedules per tick, inside maxDuration = 300. Anything left waits for the next tick, in nextRunAt order. |
| An empty result | Finishes as empty and sends nothing — a weekly schedule over a quiet source should not mail everyone an empty file every week. |
| Auth | Authorization: Bearer $CRON_SECRET when CRON_SECRET is set, matching the other crons in this repo. |
Every scheduled run writes the same sales.export audit event as a manual one,
with scheduled: true and the schedule name, so the trail is uniform.
A raw-PII artifact is kept 1 day and everything else 7 (see above). Since a schedule can never produce raw PII, its artifacts get the full 7 days — but a monthly schedule's link will expire well before the next run. That is intended: the email is a notification, not a permanent store. Re-run from the console if you need it again.
GET …/history?scope=mine|team reads mattersai.audit_logs where eventType = "sales.export". No second store to keep in sync — the audit event a run already writes
is the history record.
| Control | Where |
|---|---|
org:analytics:export or org:leads:export on every route | each route.ts |
org:analytics:view on the whole /sales subtree | app/(workspace)/sales/layout.tsx |
org:observability:pii_view for raw PII | canViewPII() → resolvePiiMode |
Collection names are z.enum-bound | export-request.schema.ts — no arbitrary Mongo reads |
| Rate limits: 20 downloads/h, 120 previews/h, 30 preset writes/h | enforceRateLimit |
Every download audited (sales.export) | auditMutation |
| Secret keys dropped before columns exist | isSecretKey |
| CSV formula injection neutralised | jsonToCsv |
| Regex search input escaped | escapeRegex |
A cell beginning =, +, -, @, TAB or CR is executable in Excel and Google Sheets.
A lead who types =IMPORTXML(...) into a public form would otherwise run code on the
analyst's machine. csv-converter.ts prefixes such
cells with a single quote — the conventional inert marker — while leaving numeric-looking
values (-5, +91 98765 43210) untouched so real numbers and phone strings survive.
Header labels are never sanitised.
The pre-multi-source body is still accepted and normalised by normalizeLegacyRequest:
collection → one-element sources, withPII → the include PII request (still
clearance-gated), excludeFields → options.excludeFields. A body that matches neither
shape returns the new schema's issues, which is the more useful diagnostic.
tests/sales-export/ — 179 tests. All but the jsdom file are pure (no database):
| File | Covers |
|---|---|
csv-converter.test.ts | RFC 4180 quoting, delimiters, BOM, column order, formula injection |
pii.test.ts | masking per kind, key classification, clearance downgrade |
lead-normalize.test.ts | flattening, depth cap, secret dropping, alias priority, name composition, cross-source column compatibility |
export-serializer.test.ts | filenames, RFC 5987 Content-Disposition, header-injection resistance |
export-xlsx.test.ts | real workbook round-trip, sheet-name truncation + de-duplication |
export-archive.test.ts | retention on both axes, size cap, clearance rule, tolerant prune |
lead-collections.test.ts | registry guards — unique ids, no duplicate physical collection, no phantom source |
leads-viewer.test.ts | the blank-email regression, composed-name availability, canonical search/sort/filter resolution |
console-ui.test.tsx | jsdom: the saved/activity tabs, the PII lock on a saved artifact, column ordering, and the URL hand-off |
export-schedules.test.ts | cadence maths under a non-UTC host zone (incl. a DST boundary), the raw-PII refusal, the recipient allowlist, and that the notification carries a link rather than data |
pnpm exec jest tests/sales-export
A new data source — add one entry to
lead-collections.ts. Discovery,
the picker, the query builder and the exporter all pick it up with no further changes.
Treat additions as a security-review item: the entry is what exposes the dataset.
Warning
Verify the collection name against the live cluster. A wrong one fails silently — the
source is reachable, the query returns nothing, and the export reports "no rows matched" exactly
as it would for an empty date range. Two entries were wrong until 2026-09-04:
career_applications pointed at a collection that has never existed (the data is in
job_applications — 4,438 documents, the largest lead dataset in the org) and
newsletter_subscribers likewise (the data is in universal_subscriptions — 122 documents).
Both were invisible to the exporter and the Leads Viewer, which share this registry.
tests/sales-export/lead-collections.test.ts pins them.
| Source | Collection | Rows |
|---|---|---|
| Demo Requests | mattersai-leads/demo-requests | 2,316 |
| Job Applications | mattersai-careers/job_applications | 4,438 |
| Webinar Registrations | mattersai-webinars/webinar_registrations | 155 |
| Newsletter Subscribers | mattersai-webinars/universal_subscriptions | 122 |
| Datasheet Leads | mattersai-datasheets/datasheet_leads | 50 |
| Case Study Downloads | mattersai-leads/case_study_leads | 3 |
| Overture Popup Leads | mattersai-leads/overture_leads | 2 |
| Whitepaper Leads | mattersai-whitepapers/whitepaper_leads | 0 — database has no collection yet |
contact_submissions was removed from the registry on 2026-09-07. It had no collection and no
writer, and /contact turns out to be an informational page that routes enquiries to /demo — so
those leads land in demo-requests. Listing it implied contact enquiries existed somewhere and were
merely empty, which was misleading. Every remaining entry has a verified write path in this repo.
createdAt on most sources#Probed 2026-09-07 — worth knowing before you write a query by hand:
| Collection | Date field |
|---|---|
demo-requests | timestamp |
job_applications | submittedAt |
webinar_registrations | registered_at |
universal_subscriptions | created_at |
datasheet_leads, case_study_leads | submitted_at |
overture_leads | created_at |
All six spellings resolve through the canonical createdAt alias list, which is exactly why the
exporter reads dates through toCanonicalRow rather than touching a field name directly.
A new canonical field — add one entry to CANONICAL_FIELDS. Order in that array is the
column order in canonical exports.
Warning
Do not start a second source list. analytics.service.ts kept its own parallel array of five
sources until 2026-09-07. It drifted both ways: it had the correct universal_subscriptions
name (which is why the registry's wrong one went unnoticed for months) while omitting job
applications, Overture leads and case-study downloads entirely — under-reporting the Sales
Analytics lead total by more than 4,000 records. All three surfaces — exporter, Leads Viewer,
Analytics — now derive from LEAD_COLLECTIONS.
A new output format — add the case to serializeExport plus its CONTENT_TYPES /
EXTENSIONS entries, and one option in OutputPanel.
per_source CSV is sectioned rather than multi-file; a ZIP container would need a zip
dependency in the workspace app (exceljs is the only archive-capable dep reachable
today, and it produces XLSX).sales.export events landleadJourney is exported like any other field