Exports & Imports — Behavior
Exports
Enqueue
POST /organizations/:orgId/export/members with body ExportMembersDto:
{
roles?: ('owner' | 'admin' | 'coach' | 'member')[];
statuses?: ('active' | 'invited' | 'suspended' | 'cancelled')[];
fields?: string[]; // whitelist of columns
}Steps:
MembershipsService.requireMembership— must beowneroradmin, else403.- Insert
export_jobsrow withstatus: 'pending',config: <dto>,startedBy: <userId>. - Enqueue
exportBullMQ job{ jobId, orgId }. - Return
{ data: { jobId } }.
Processor (ExportProcessor → ExportService.processExport)
- Mark job
in_progress,startedAt. - Build a
membershipsquery with the filters; joinusers(left-joinmember_profiles). - Whitelist columns from
config.fieldsif present. - Encode as CSV with UTF-8 BOM (
) for Excel compatibility. - Upload to
exports/<orgId>/<jobId>.csv. - Mark job
completed,results: { rowCount, fileSize },fileKey. - Email the requester via
EmailService.sendwith a 1h presigned download URL.
Status polling
GET /organizations/:orgId/export/jobs— list jobs newest first.GET /organizations/:orgId/export/jobs/:jobId— whenstatus === 'completed', the response includes a freshly-minteddownloadUrlvalid for the default presign TTL.
Failure
- Errors in the processor mark the job
failedwitherrorMessage. The user sees the failure in the export jobs list. No automatic retry — owner re-issues.
Rate limit
@Throttle({ default: { ttl: 60000, limit: 10 } })on the controller — 10 requests per minute per IP.
Analytics tab CSV (synchronous)
GET /organizations/:orgId/analytics/export?tab=<tab> — owner / admin only. Streams CSV synchronously (no queue). Tabs: revenue-summary, revenue-trend, plan-distribution, members-summary, members-growth, popular-classes, class-utilization, workouts-summary. The handler builds the same rows the dashboard uses then writes them as CSV with a Content-Disposition: attachment header.
Imports — Arbox
Provider configuration
POST /organizations/:orgId/import/provider-config accepts { apiKey } only:
- Owner / admin only.
- The API key is encrypted at rest and is never returned or copied into
import_jobs.config. - Reconfiguring Arbox removes the previous encrypted provider row for that org.
DELETE /organizations/:orgId/import/provider-config/:configIderases the encrypted key.
Trigger and Public V3 import
POST /organizations/:orgId/import accepts an ImportConfigDto for source: 'arbox' with selected members and plans. Arbox lead import is rejected until an official endpoint is validated.
- Owner check, then
ensureNoActiveImport(orgId)— only one import per org at a time. - The job snapshot contains import selections only. It contains no API key, password, email, session, or token.
- The worker reads the encrypted API key only at execution time.
- The only customer import transport is
https://arboxserver.arboxapp.com/api/public/v3, authenticated withapi-key. /userssupplies members and/membershipTypessupplies plans. Member activity is derived from the documented Public V3activefield where supplied by the authorized source.- Missing source fields remain missing: there is no customer-session fallback and no inferred inactive-history claim.
Receipts (enrichment phase)
Before the per-member enrichment jobs fan out, ArboxReceiptImportService copies every receipt Arbox issued to the imported members into imported_receipts.
- Source:
GET /v3/reports/transactionsReport, read box-wide in consecutive 31-day windows (Arbox’s maximum; both ends inclusive) from the earliest Arboxcreated_timeamong imported members to today in the org timezone, 500 rows per page. One box-wide sweep is a few hundred requests; a per-memberuser_idsweep would be one sweep per member. - A row is a receipt when its
receiptfield holds anhttps://document link. Failed charges carry none and are skipped. Rows of people who were not imported are dropped. - Each receipt is attached to the member’s membership, with the box-local time converted to an instant, the amount in cents, the org currency, installments and the card’s last four digits.
- Idempotent:
(organization_id, source, external_id)is unique on the Arbox transaction id, so a re-run imports only what is missing. - A window Arbox fails to serve after retries is skipped and listed in
results.receipts.failedWindows; the owner sees a warning toast and re-running the import fills the gap. The import itself still completes. - History only. Receipts never enter
payment_transactions, so billing, refunds and revenue analytics never count money Taikan did not collect. - Staff with
payments:managesee them on the member’s Memberships tab under “Receipts from Arbox” (GET /organizations/:orgId/payments/members/:membershipId/imported-receipts). The section is absent for members with none. - The document link is a bearer URL: Arbox’s invoicing provider (Rivhit on the pilot box) serves the PDF, naming the member, to anyone holding it. Member erasure deletes the rows.
- Boxes on Arbox’s own invoicing module may not put a link in
receipt; their documents sit behind/v3/finance/reports/*, which is not read.
Data minimization
Normal Arbox and CSV member imports persist selected identity, contact, birthday, gender, membership, and plan data. They do not persist address, national ID, secondary/emergency contacts, medical certificates, RFID/check-in identifiers, or child-status fields.
Break-glass session workflow
Customer imports never use a management API or customer password. The only session capability is the server-side ArboxBreakGlassService, invoked through scripts/arbox-break-glass.ts; it has no HTTP controller or UI.
Every configuration or revoke requires the environment gate and Clerk entitlement. Every run additionally requires:
ARBOX_BREAK_GLASS_ENABLED=true;- a Clerk session token for a user whose
privateMetadata.arboxBreakGlassis exactlytrue; - a customer-authorization reference and an internal-approval reference;
- a non-expired, bounded authorization window.
Credentials are encrypted in arbox_break_glass_credentials; runs record actor, authorization references, bounded window, outcome, and aggregate counts in arbox_break_glass_runs. Secrets, session tokens, and report payloads are not recorded in jobs or audit records. Revoke deletes the credential row.
Legacy credential scrub
Migration 0135_arbox_import_security.sql deletes legacy Arbox provider credentials and removes API-key, email, password, and legacy Arbox credential fields from existing Arbox job snapshots.
Imports — CSV
Upload
POST /organizations/:orgId/import/csv/upload (multipart):
- 5 MB cap,
FileTypeValidator({ fileType: 'csv', skipMagicNumbersValidation: true }). - Cancels stale pending CSV jobs for the org.
ensureNoActiveImport(orgId)guard.- Parses with
CsvParseService.parseAndValidate(buffer)→{ headers, rows, totalRows }. suggestMapping(headers)returns a heuristic header → field map (email,firstName,phone, etc.).- Inserts
import_jobswithsource: 'csv',status: 'pending',config: { parsedRows, headers, suggestedMapping }. - Returns
{ jobId, headers, suggestedMapping, previewRows: rows.slice(0,5), totalRows }.
Confirm
POST /organizations/:orgId/import/csv/jobs/:jobId/confirm body CsvImportConfirmDto:
{
columnMapping: { memberFields: Record<csvHeader, taikanField> };
entities: { members, plans, leads };
memberFields?: MemberFieldsDto;
planFields?: PlanFieldsDto;
}- Owner only.
- Validates the job exists, is
csv, inpendingstate. - Merges the confirmed mapping into
import_jobs.config. - Enqueues
importjob.
Completion
- CSV member imports complete when all member jobs finish. They never read Arbox credentials or enqueue the Arbox enrichment phase.
- A queued legacy CSV enrichment job completes without invoking Arbox.
Cancel
DELETE /organizations/:orgId/import/jobs/:jobId — only effective on pending or in_progress jobs.
Job status
GET /organizations/:orgId/import/jobs and /jobs/:jobId mirror the export endpoints — both return the full row with phase, results, errorMessage.
Idempotency
- Imports upsert by email + org. Re-running an Arbox import overwrites profile data with the latest source.
- Single in-flight import per org (
ensureNoActiveImport). - Member-level retries handled by BullMQ (3 attempts).
Failure modes
| Failure | Surface | Recovery |
|---|---|---|
| Owner role missing | 403 Only owners and admins can import/export | Re-attempt with the owner account. |
| Two imports started simultaneously | 409 Active import already exists | Wait for the running one to finish or cancel. |
| Arbox auth expired | Job moves to failed; errorMessage cites 401 | Re-enter credentials. |
| Single member row fails (bad email, missing required field) | BullMQ retries up to 3, then drops; error captured in results.errors | Owner inspects + fixes upstream, reruns. |
| Encrypted credentials unreadable (FIT-120) | First job to use them fails decrypting | Re-enter credentials. |
| CSV exceeds 5 MB | 400 File too large | Split the CSV. |
| CSV preview parses with garbled headers | suggestMapping returns empty; owner maps manually. | UI flow. |
Managed members
Spec: managed-members/behavior.md §3.4.
- With the
managed-membersflag on, a CSV row with a truthyisChildcolumn or no email becomes a managed member, linked to the adult named inparentEmail/parentPhonewith a pending link (apps/api/src/import/import.service.ts:793-806). With it off, rows behave as before (emailless rows skipped). - Rows that can’t be placed go to the reconciliation queue:
no_guardian,no_birth_date,guardian_ambiguous(import.service.ts:1367-1371;GET/POST import/jobs/:jobId/reconciliation). No screen for it yet. Surname matching is never used. - The Arbox direct sync does not create children; only the CSV import does.