Contents Capture integration
Contents Capture is the field-capture platform (an iOS app backed by a Cloudflare Workers API, D1 metadata database, and a private R2 media bucket). ContentsVision integrates with it as a pull-based media source: users browse a connected Capture org like a folder tree and import selected media into claims.
Architecture
ContentsVision PULLS media. All Capture traffic is server-to-server from the backend; the browser never talks to the Capture API and never sees the org token.
ContentsVision UI
| /api/integrations/... (team-scoped)
ContentsVision backend --Bearer cci_ token--> Contents Capture API
| |
| <-- short-lived presigned R2 GET URLs ------+
+--> downloads bytes, creates claims/rooms/jobs
Key properties:
- Org-level access. A Contents Capture org admin mints an org integration
token (
cci_...) in the Capture admin console. A ContentsVision team admin pastes it under Settings, then Integrations. The token is read-only on the Capture side (its principal is an org-wide observer) and is validated againstGET /integrations/whoamibefore it is stored. - The Capture API is the source of truth. Only media with
syncState == "synced"has its original durably stored; browse and import filter on it. ContentsVision holds no R2 credentials: bytes are fetched through presigned GET URLs the Capture API signs per media id (1 hour TTL, signed in batches immediately before download). - Entity mapping. Capture assignment -> claim (the assignment
referencebecomes the claim number), location -> room (matched by name, created when missing), group -> staged photogroup_index(grouping arrives pre-done), photo ->StagedPhotoin one photo job per room, video/audio -> one job per file. - Billing is never bypassed. Imported photo jobs park at
awaiting_groupingand video/audio jobs atawaiting_confirmation; the user starts them through the normal quote/process step.
Data model
Three tables, declared in backend/app/models/integration.py and registered in
database.py:init_db() (whole new tables, so no ALTER lines):
| Table | Purpose |
|---|---|
storage_connections | One configured provider per team (kind = contents_capture). Holds the Capture org id (external_account_id) and the org token (api_token, following the Team.team_token precedent; secret_ref is the upgrade path to a secret manager). |
uploads | One piece of source media with provenance: (connection_id, external_media_id) is the idempotency key, sha256/size_bytes come from the Capture variant record, meta snapshots the capture tree (assignment/location/group ids). Lifecycle: `registered -> importing -> ready |
import_batches | One user-initiated import run: the selection manifest verbatim, progress counters (total/imported/skipped/failed), the claim it landed in, and the job ids it created. The UI polls this row. |
Provider layer
backend/app/services/integrations/ holds the adapter layer:
base.pydefines theStorageProviderinterface (browse a virtual tree, stat one item, stream its bytes) plusProviderCapabilities, so future providers (S3, Dropbox, ...) plug in without touching callers.registry.pymapsStorageConnection.kindto a provider class via@register_provider/build_provider.contents_capture.pyimplements the Capture provider:CaptureClientis a thin async httpx client over the Capture read API (same house style asadjust_square.py), andContentsCaptureProvideris registered undercontents_capture.importer.pyis the import engine (see below).
API endpoints
All under /api/integrations, team-scoped via the standard Actor dependency
(backend/app/api/routes/integrations.py):
| Endpoint | Purpose |
|---|---|
GET /integrations/connections | The team's connections plus contents_capture_available (false when the server has no base URL configured, which hides the integration in the UI). |
POST /integrations/contents-capture/connect | Validate a pasted cci_ token via whoami and store/replace the team's connection. |
DELETE /integrations/connections/{id} | Disconnect. |
GET .../connections/{id}/teams, .../teams/{tid}/assignments, .../assignments/{aid}/tree, .../assignments/{aid}/media | Browse proxy over the Capture read API. The media page passes Capture's presigned thumbnail URLs through to the grid. |
POST .../connections/{id}/import | Save a selection manifest on an ImportBatch and enqueue run_capture_import. |
GET /integrations/imports/{batch_id} | Poll import progress. |
The selection manifest
The import request is selector-based, never a flat list of media ids, so selecting "everything" stays a tiny request and the server expands it:
{
"assignment_id": "...",
"target": {"mode": "new_claim" | "existing_claim" | "room", "claim_id": "...", "room_id": "..."},
"select": [{"scope": "assignment"} , {"scope": "location", "id": "..."},
{"scope": "group", "id": "..."}, {"scope": "media", "ids": ["..."]}],
"exclude": [ ...same shapes... ],
"filters": {"types": ["photo"], "submitted_only": false}
}
The import job
run_capture_import runs as an ARQ function (Stage 2) or a BackgroundTask
(Stage 1), registered in worker.py and core/queue.py. Per batch it:
- expands the manifest against the Capture API (synced media only, cursor-paginated), applies excludes and filters, deduplicates;
- skips media already imported on this connection (idempotency key), counting them as skipped;
- resolves the target claim and rooms, creating them as needed;
- signs original URLs in batches of 200 immediately before downloading
(bounded concurrency,
CONTENTS_CAPTURE_IMPORT_CONCURRENCY), normalizes photos to JPEG (falling back to the Capture proxy variant when the original is not Pillow-readable, e.g. HEIC), seedsstaged_photoswith group indices carried over from Capture groups; - updates the batch counters as it goes and finishes
complete,partial(some files failed; re-running the import retries just those), orfailed.
Configuration
| Setting | Meaning |
|---|---|
CONTENTS_CAPTURE_API_BASE_URL | The Capture API base URL. Empty (default) disables the integration entirely. Dev/stage: https://contents-capture-api.dev-908.workers.dev. |
CONTENTS_CAPTURE_WEBHOOK_SECRET | HMAC secret for Capture webhooks. Optional; webhooks are a later phase. |
CONTENTS_CAPTURE_IMPORT_CONCURRENCY | Parallel downloads per import run (default 4). |
Per-team org tokens live on storage_connections.api_token, not in the
environment.
Frontend
- Settings, Integrations tab (
frontend/src/components/IntegrationsCard.tsx): connect (token paste + validate), connected state, disconnect. - Import browser (
frontend/src/components/CaptureImportModal.tsx): a folder-style explorer (assignment folders on the left, media grid on the right) with tri-state folder checkboxes, shift-click range select, type and submitted-only filters, selector-based selection, and a polling progress view. Opened from the Dashboard ("new claim" imports) and the claim page ("into this claim" imports); the buttons render only when the team has an active connection.
Production cutover
The integration currently points at the Capture dev/stage stack. Moving to
production means: provision the Capture production environment (Worker, D1,
R2, Clerk live), set CONTENTS_CAPTURE_API_BASE_URL to the production host,
and have org admins mint production tokens. No code changes.