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.- A source user without an email is not imported. The count is reported as
results.members.noEmailand the owner sees a warning toast. - A membership’s
joinedAtis the Arboxcreated_time(when the person entered Arbox);purchase_datestarts the current subscription instead. - Birthdays and dates that are not real calendar dates (Arbox’s
0000-00-00) are stored as empty rather than failing the row. - Phase transitions are claimed atomically: when several member (or enrichment) jobs finish at once, only the one that moves the job out of its phase starts the next one. Row, enrichment and phase jobs carry fixed BullMQ ids (
<job>-member-<row>,<job>-enrich-<arboxUserId>,<job>-enrichment). - Every Arbox request runs one at a time: Arbox answers 429 “CONCURRENT REQUESTS NOT ALLOWED” whenever another request on the key is in flight. A 429 is retried with a longer backoff than other failures.
Membership history (enrichment phase)
Before receipts, ArboxMembershipHistoryImportService copies every membership the imported members held in Arbox, current and past, into imported_memberships.
- Source:
GET /v3/users/memberships, read box-wide in pages. - Idempotent on
(organization_id, source, external_id)with the Arboxmembership_user_id. - History only, like receipts: not subscriptions, never billed, never in entitlements, churn or MRR. Only the member’s current Arbox membership becomes a Taikan subscription (matched by plan name, once per membership and plan, even on re-runs).
- The history dates inactive members only: their cancelled subscription’s
cancelledAtand their membership’sendDateare the end of their last Arbox membership, not the day of the import. Active subscriptions get no end date from Arbox: an end date on a live subscription is enforced (bookings past it are refused, the lifecycle sweep expires it), and Arbox’sendon a recurring membership is not known to mean it stops. - If Arbox cannot serve the list,
results.membershipHistory.failedis true, the owner sees a warning toast, and the import still completes. Re-running fills it in. - Staff with
payments:managesee it on the member’s Memberships tab under “Memberships from Arbox” (GET /organizations/:orgId/payments/members/:membershipId/imported-memberships). The section is absent for members with none. Member erasure deletes the rows. - 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 retried once more after the sweep; one that still fails is 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.