Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesExports ImportsExports & Imports — Behavior

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:

  1. MembershipsService.requireMembership — must be owner or admin, else 403.
  2. Insert export_jobs row with status: 'pending', config: <dto>, startedBy: <userId>.
  3. Enqueue export BullMQ job { jobId, orgId }.
  4. Return { data: { jobId } }.

Processor (ExportProcessor → ExportService.processExport)

  1. Mark job in_progress, startedAt.
  2. Build a memberships query with the filters; join users (left-join member_profiles).
  3. Whitelist columns from config.fields if present.
  4. Encode as CSV with UTF-8 BOM () for Excel compatibility.
  5. Upload to exports/<orgId>/<jobId>.csv.
  6. Mark job completed, results: { rowCount, fileSize }, fileKey.
  7. Email the requester via EmailService.send with a 1h presigned download URL.

Status polling

  • GET /organizations/:orgId/export/jobs — list jobs newest first.
  • GET /organizations/:orgId/export/jobs/:jobId — when status === 'completed', the response includes a freshly-minted downloadUrl valid for the default presign TTL.

Failure

  • Errors in the processor mark the job failed with errorMessage. 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/:configId erases 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 with api-key.
  • /users supplies members and /membershipTypes supplies plans. Member activity is derived from the documented Public V3 active field where supplied by the authorized source.
  • A source user without an email is not imported. The count is reported as results.members.noEmail and the owner sees a warning toast.
  • A membership’s joinedAt is the Arbox created_time (when the person entered Arbox); purchase_date starts 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 Arbox membership_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 cancelledAt and their membership’s endDate are 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’s end on a recurring membership is not known to mean it stops.
  • If Arbox cannot serve the list, results.membershipHistory.failed is true, the owner sees a warning toast, and the import still completes. Re-running fills it in.
  • Staff with payments:manage see 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 Arbox created_time among imported members to today in the org timezone, 500 rows per page. One box-wide sweep is a few hundred requests; a per-member user_id sweep would be one sweep per member.
  • A row is a receipt when its receipt field holds an https:// 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:manage see 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:

  1. ARBOX_BREAK_GLASS_ENABLED=true;
  2. a Clerk session token for a user whose privateMetadata.arboxBreakGlass is exactly true;
  3. a customer-authorization reference and an internal-approval reference;
  4. 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_jobs with source: '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, in pending state.
  • Merges the confirmed mapping into import_jobs.config.
  • Enqueues import job.

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

FailureSurfaceRecovery
Owner role missing403 Only owners and admins can import/exportRe-attempt with the owner account.
Two imports started simultaneously409 Active import already existsWait for the running one to finish or cancel.
Arbox auth expiredJob moves to failed; errorMessage cites 401Re-enter credentials.
Single member row fails (bad email, missing required field)BullMQ retries up to 3, then drops; error captured in results.errorsOwner inspects + fixes upstream, reruns.
Encrypted credentials unreadable (FIT-120)First job to use them fails decryptingRe-enter credentials.
CSV exceeds 5 MB400 File too largeSplit the CSV.
CSV preview parses with garbled headerssuggestMapping returns empty; owner maps manually.UI flow.

Managed members

Spec: managed-members/behavior.md §3.4.

  • With the managed-members flag on, a CSV row with a truthy isChild column or no email becomes a managed member, linked to the adult named in parentEmail / parentPhone with 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.