Backend API reference
The backend is a FastAPI app. All routes are mounted under the /api prefix in backend/app/main.py and grouped into ten routers. Interactive OpenAPI docs are always available at /docs (and /redoc) on a running backend.
The auth model
Almost every endpoint requires an AdjustSquare JWT. The token arrives either as an Authorization: Bearer <jwt> header or as the bt_shared_data cookie. Two FastAPI dependencies wrap this:
| Dependency | Returns | Used when |
|---|---|---|
get_current_user | the User ORM row (auto-provisioned) | the route needs the full user or team |
get_actor | a lightweight Actor(user_id, user_name, team_id) | the route writes an activity-log entry |
require_admin | the User ORM row, only if on the admin allowlist | the route is part of the internal admin dashboard (/api/admin/...) |
get_actor depends on get_current_user, so both resolve the same token once per request. Team scoping is enforced in the route: reads and writes check that the target claim's team_id matches the caller's, returning 403 on mismatch and 404 when the record does not exist. See authentication for the validation flow.
Some job, room, and inventory read endpoints resolve by id without a team check (they are reached only after a team-scoped claim load in the UI). Treat the claim-level checks as the security boundary. This is noted per group below.
auth
| Method & path | Purpose | Auth |
|---|---|---|
GET /api/auth/me | Return the current user and team, including is_admin, the user's timezone preference, and features (the team's resolved component visibility, e.g. {"training": true}); logs a login event when called with a bearer token (the callback exchange). | get_current_user |
PUT /api/auth/me | Update the caller's display preferences. Currently {timezone}: an IANA zone name validated against the zoneinfo database, or "" for browser-local time. Returns the refreshed /auth/me payload. | get_current_user |
POST /api/auth/logout | Record a logout activity event. | get_actor |
GET /api/auth/config | Public: returns the AdjustSquare login URL so the frontend can redirect. | none |
GET /api/auth/debug | Debug view of the signed-in identity: resolved user and team, raw JWT claims when a token is present, auth mode (local bypass vs JWT), and Adjust Square readiness (base URL set + a team/fallback token). The team token is masked, never returned in full. | get_current_user |
claims
| Method & path | Purpose | Auth & scope |
|---|---|---|
POST /api/claims | Create a claim for the caller's team. Writes activity. | get_actor |
GET /api/claims | List the team's claims with per-claim spend. Active claims by default; ?archived=true lists the archived ones. | get_current_user, team-scoped |
GET /api/claims/{id} | Get one claim with spend. | get_current_user, 403 on other team |
PATCH /api/claims/{id} | Inline-edit claim fields. Writes activity. | get_actor, 403 on other team |
POST /api/claims/{id}/archive | Archive a claim (soft-hide; sets is_archived + archived_at). Reversible and non-destructive. 400 if the claim has been submitted (submitted_at set). Writes activity. | get_actor, 403 on other team |
POST /api/claims/{id}/unarchive | Restore an archived claim (clears archived_at). Writes activity. | get_actor, 403 on other team |
DELETE /api/claims/{id} | Internal/admin hard delete of a claim and everything beneath it (rooms, jobs, items, item/staged photos, files on disk). No longer exposed in the UI — archiving is the only user-facing removal, and the 30-day purge is the only deletion. The endpoint remains for the purge/admin use. | require_admin (allowlist {7, 10}), not team-scoped |
GET /api/claims/{id}/adjust-square-profiles | The team's shopping + depreciation profiles for the submit modal. | get_current_user, 403 on other team |
POST /api/claims/{id}/submit-to-adjust-square | Submit selected rooms' items to Adjust Square. Records a Submission row (date, item/photo counts, claim key, end-to-end processing time; status flips from processing to complete when the background item/photo work finishes). | get_actor + get_current_user, 403/409 |
GET /api/claims/{id}/submissions | The claim's submission history, newest first, plus total_items / unsubmitted_items (the UI disables Send for Estimation when everything is submitted). | get_actor, team-scoped with the admin exception |
The submit endpoint is the most complex in the system: it authenticates to Adjust Square as the user's team, creates or updates the remote claim, and spawns background tasks for photo upload and AI triggering. It returns 409 if the team has no Adjust Square token. Archived rooms and archived items are excluded from the submission payload. Full detail in the Adjust Square integration.
rooms
| Method & path | Purpose | Auth & scope |
|---|---|---|
POST /api/claims/{claim_id}/rooms | Add a room to a claim. Writes activity. | get_actor, 403 on other team |
GET /api/claims/{claim_id}/rooms | List rooms with media and item counts (video/photo/audio/items/submitted). Active rooms by default; ?archived=true lists archived ones. Item counts exclude archived items. | get_current_user, 403 on other team |
GET /api/rooms/{room_id} | Get a room with submitted/unsubmitted counts. | none (by id) |
POST /api/rooms/{room_id}/archive | Archive a room (sets is_archived + archived_at). 400 if the room contains any submitted item. Reversible. Writes activity. | get_actor, 403 on other team (via parent claim) |
POST /api/rooms/{room_id}/unarchive | Restore an archived room (clears archived_at). Writes activity. | get_actor, 403 on other team |
PUT /api/rooms/{room_id} | Rename a room. Writes a non-restorable rename_room activity entry. | get_actor, 403 on other team (checked via the owning claim) |
GET /api/rooms/{room_id}/jobs | List a room's jobs with live item counts. | none (by id) |
upload
All upload endpoints create a job and write a (non-restorable) activity entry.
| Method & path | Purpose | Behavior |
|---|---|---|
POST /api/upload | Upload a video to a room. | Streams to disk (size-capped), creates a job, starts the video pipeline in the background. |
POST /api/upload-photos | Upload the first batch of photos to a room. | Normalizes each to JPEG, seeds one StagedPhoto per file, leaves the job in awaiting_grouping. Does not auto-start AI. |
POST /api/upload-photos/{id}/append | Add more photos to an existing photo job. | Continues the orig_####.jpg numbering and group indices, adds more StagedPhoto rows, and updates total_frames / file_size_bytes. Only while the job is awaiting_grouping. |
POST /api/upload-audio | Upload an audio file to a room. | Streams to disk, creates a job, starts the audio pipeline in the background. |
Each takes room_id as form data and the file(s) as multipart, validates the extension, and enforces its size cap (returns 413 if exceeded): MAX_UPLOAD_SIZE_MB for video/audio, MAX_PHOTO_UPLOAD_MB (running total across the whole job) for photos. Auth: get_actor.
Batched photo upload
Production traffic runs through a Cloudflare Tunnel that caps a single request body at roughly 100MB, so the frontend never sends a whole photo selection in one request. It greedy-fills the files into batches up to an 80MB budget (headroom for multipart boundaries and the encoded body), sends the first batch to POST /api/upload-photos, then sends each remaining batch to POST /api/upload-photos/{id}/append. The result is one room equals one job with one grouping pile, just delivered over several requests. The append endpoint rejects anything but an awaiting_grouping photo job (409/400), and MAX_PHOTO_UPLOAD_MB is enforced as a running total across the whole job, not per request. A single photo over the 100MB hard limit is caught in the browser up front with a clear message, since it can never succeed batched or not.
Parallelism (speed). Two things make a large (hundreds of photos) upload fast rather than crawl:
- Parallel batches. Batch 0 creates the job and must land first; every remaining batch is an append and the client uploads up to 4 at a time (
PHOTO_UPLOAD_CONCURRENCYinlib/api.ts). To keep parallel appends from racing on theorig_####numbering, the client assigns each batch astart_index(its non-overlapping slice of the ordered selection) and the server writesorig_{start_index+i}.jpg. A skipped (unreadable) file just leaves a gap in the index range, which grouping tolerates. The job'stotal_frames/file_size_bytesare bumped with an atomic in-DB increment so concurrent appends can't clobber each other's counts. Clients that omitstart_indexfall back to appending after the current row count (serial-only). - Threaded re-encode. Each photo is normalized to JPEG in a thread pool (
_encode_photos_concurrentlyinroutes/upload.py), not one-at-a-time on the event loop, so a batch's per-photo CPU overlaps across cores and never blocks other requests. Pillow releases the GIL inside its C codecs, so this is real parallelism.
Both endpoints return skipped_photos (the unreadable files they dropped); the frontend aggregates it across every batch and shows it in the uploader's "files skipped" notice, and also renders live per-photo tiles (queued → uploading → done/skipped) plus elapsed time and a byte-rate ETA.
Chunked, resumable uploads
The single-shot POST /api/upload (and /api/upload-audio) sends the whole file in one request, which fails when a CDN or tunnel caps the request body (Cloudflare's tunnel rejects bodies over ~100 MB on Free/Pro, well below the app's 2 GB). The chunked endpoints split a large file into small parts so each request stays under that cap, while the app still accepts the full size. The frontend uses these by default for video and audio (uploadVideo/uploadAudio in lib/api.ts); the one-shot endpoints remain for compatibility.
| Method & path | Purpose |
|---|---|
POST /api/uploads/init | Open a session. JSON body { kind: "video"|"audio", filename, room_id, total_size }. Validates the room and extension up front and returns { upload_id, chunk_size, received }. |
PUT /api/uploads/{id}/chunks/{index} | Store one chunk (raw body). Idempotent: re-sending an index overwrites it, which makes retries safe. |
GET /api/uploads/{id} | Report received chunk indices so a client can resume by sending only the missing ones. |
POST /api/uploads/{id}/complete | Concatenate the chunks in order into the final file and hand it to the pipeline, exactly like a one-shot upload. Returns the Job. |
DELETE /api/uploads/{id} | Abort and discard the partial upload. |
Auth: get_actor on every endpoint. Chunks are written to a per-upload scratch dir (uploads/.chunks/{id}/) with an atomic rename per part, so a crash mid-write never leaves a half-written part. complete verifies the parts form a gap-free 0..n-1 run and (if total_size was given) that the assembled size matches, returning 400 otherwise. Per-chunk size is hard-capped (MAX_CHUNK_BYTES, 32 MB) and the total still honours MAX_UPLOAD_SIZE_MB. The upload_id is validated as a UUID, so it cannot traverse the filesystem.
jobs
The jobs router carries job reads, media serving, photo staging, and the progress stream.
| Method & path | Purpose |
|---|---|
GET /api/jobs | List the 50 most recent jobs. |
GET /api/jobs/{id} | Get one job. |
GET /api/jobs/{id}/video | Stream the original video (HTTP range support for seeking). |
GET /api/jobs/{id}/audio | Stream the original audio (range support). |
GET /api/jobs/{id}/frames | List extracted frame URLs. |
GET /api/jobs/{id}/frames/{name} | Serve one frame image. |
GET /api/jobs/{id}/photos | List uploaded source photo URLs. |
GET /api/jobs/{id}/staged-photos | List staged photos with their groupings. Each photo includes its original_filename (the real upload name behind the synthetic orig_####.jpg) and captured_at (EXIF date taken), which the grouping UI's "Sort by name" / "Sort by date" use. POST /upload-photos and .../append accept an optional captured_ats form field (JSON array of dates aligned to files). |
PUT /api/jobs/{id}/staged-photos/regroup | Replace all group assignments (enforces MAX_PHOTOS_PER_ITEM; leaves manual segments untouched). |
PUT /api/jobs/{id}/staged-photos/delete | Soft-delete staged photos. |
PUT /api/jobs/{id}/staged-photos/restore | Restore soft-deleted staged photos. |
PUT /api/jobs/{id}/staged-photos/segment | Crop user-drawn boxes from one photo into known items (AS-705); hides the original. Each item has a name (blank = AI names it), a quantity, and one or more boxes. |
PUT /api/jobs/{id}/staged-photos/clear-segments | Remove a photo's segment crops and restore the original whole photo. |
PUT /api/jobs/{id}/staged-photos/details | Set a user-written name + quantity for one item (its staged-photo ids). A non-empty name marks it user-described, so the AI is skipped and it costs no credit; an empty name hands it back to the AI. |
GET /api/jobs/{id}/quote | Exact credit cost to process the job plus the team's balance, for the confirmation modal (charges nothing). |
POST /api/jobs/{id}/process | Confirm and start processing an uploaded job (photo after grouping, or video/audio after the credit confirmation). Spends the credits up front (402 if short), then starts the matching pipeline. Accepts awaiting_grouping / awaiting_confirmation / error. |
POST /api/jobs/{id}/cancel | Discard a not-yet-started job (e.g. a cancelled confirmation), deleting its file. |
GET /api/jobs/{id}/logs | The job's log lines: the in-memory buffer while it runs, the job_log_archives copy once finished. |
GET /api/jobs/{id}/metrics | Timing, token, and cost summary. |
GET /api/jobs/{id}/stream | Server-Sent Events stream of progress, logs, and final metrics. |
These endpoints are id-addressed and not team-checked; they back the processing and review UI.
The SSE progress stream
GET /api/jobs/{id}/stream polls the job every ~1.5 seconds (up to ~10 minutes) and emits three event types: progress (status, percent, current stage), log (new log lines), and a final metrics event when the job reaches complete or error. Caching and buffering are disabled so events arrive immediately through nginx and the Next dev proxy.
inventory
The largest router: item CRUD, approval, bulk edits, multi-photo management, merging, and exports.
| Method & path | Purpose |
|---|---|
GET /api/inventory/{job_id} | List a job's items (with photos), ordered by item number. Active items by default; ?archived=true lists archived ones. |
PUT /api/inventory/item/{item_id} | Edit an item; marks it reviewed. Writes activity. |
POST /api/inventory/item/{item_id}/archive | Archive an item (sets is_archived + archived_at; item_number untouched). Rejected with 400 for submitted items. Writes activity. |
POST /api/inventory/item/{item_id}/unarchive | Restore an archived item (clears archived_at). Writes activity. |
POST /api/inventory/{job_id}/approve | Approve or unapprove one item. Writes activity. |
POST /api/inventory/{job_id}/auto-approve | Apply saved auto-approval rules to unapproved items. |
POST /api/inventory/{job_id}/bulk | Apply one field patch to many items (skips submitted items). |
POST /api/inventory/{job_id}/item | Manually add an item (supports insert-between numbering). |
DELETE /api/inventory/item/{item_id} | Internal hard delete of an item (snapshot stored for restore). No longer exposed in the UI — archiving replaced it. Retained for the undo-of-add path and the 30-day purge. |
PUT /api/inventory/item/{item_id}/photo | Replace an item's primary photo. |
POST /api/inventory/item/{item_id}/photos | Attach a photo to an item. |
DELETE /api/inventory/item/{item_id}/photos/{photo_id} | Remove a photo (promotes the next if primary). |
PUT /api/inventory/item/{item_id}/photos/{photo_id} | Set primary / reorder a photo. |
PUT /api/inventory/item/{item_id}/photos/{photo_id}/move | Move a photo to another item. |
POST /api/inventory/items/merge | Merge several items into one (reassigns photos, deletes losers). |
GET /api/inventory/{job_id}/export/excel | Excel export (summary + per-room sheets); ?approved_only=true. Archived items are always excluded. |
GET /api/inventory/{job_id}/export/csv | CSV export; ?approved_only=true. Archived items are always excluded. |
GET /api/rooms/{room_id}/inventory | All items across every job in a room. Active items by default; ?archived=true lists archived ones. |
GET /api/rooms/{room_id}/export/excel | Room-level Excel export. |
GET /api/rooms/{room_id}/export/csv | Room-level CSV export. |
GET /api/photos/{job_id}/{filename} | Serve a stored photo file. |
Mutating endpoints use get_actor and write activity entries (most with a before snapshot enabling restore).
The archive lifecycle and the 30-day purge
Claims, rooms, and items are never deleted by hand. The only removal action in the UI is archive (is_archived + archived_at), which hides the thing from the default lists, counts, exports, and Adjust Square submission while keeping every row and file. Archiving is blocked once something is submitted (a claim with submitted_at, a room holding any submitted item, or a submitted item). Restoring clears archived_at.
A background job (app.services.archive_cleanup.purge_expired_archived, started from app.main.lifespan) runs on startup and then once a day. It permanently deletes anything whose archived_at is more than 30 days old, cascading to everything beneath it and removing its files on disk. This is the only automatic deletion in the system.
settings
| Method & path | Purpose |
|---|---|
GET /api/settings/automation | Get the auto-approval rules. |
PUT /api/settings/automation | Save auto-approval rules. Writes activity with a before snapshot. |
GET /api/settings/prompts | Get the AI prompt settings bundle (presets, built-in defaults, char cap). |
PUT /api/settings/prompts | Validate and save prompt settings; logs exactly what changed. |
Settings are stored as JSON files on disk (not in the database). See automation and prompts.
activity
| Method & path | Purpose |
|---|---|
GET /api/activity | The activity log feed. |
POST /api/activity/{log_id}/restore | Undo a restorable action from its snapshot. |
billing
| Method & path | Purpose | Auth |
|---|---|---|
GET /api/billing/summary | The team's spend total (total_spend_usd) and the flat credit rates (video/photo/audio). When ENABLE_BILLING is on it also returns billing_enabled, the team's prepaid credits_balance, and a buy_credits_url, which drive the persistent credits widget (the team's available balance + a Buy credits button). When billing is off those are false/null and the widget is hidden. | get_current_user, team-scoped |
When prepaid billing is enabled, an upload does not process immediately: video/audio jobs park in awaiting_confirmation (photos in awaiting_grouping) and the client first calls GET /api/jobs/{id}/quote for the exact cost + balance to show a confirmation modal. Credits are spent at confirm — POST /api/jobs/{id}/process spends up front (the gate) and returns 402 with { error: "insufficient_credits", credits_balance, required_amount, buy_url } when the team cannot cover it, otherwise it starts the pipeline. The spend fails open if the billing service is unreachable, a cancelled job is discarded via POST /api/jobs/{id}/cancel, and a job that later fails processing is refunded. See billing.
dashboard
| Method & path | Purpose | Auth |
|---|---|---|
GET /api/dashboard/metrics | Team-scoped headline numbers for the Dashboard stat cards. Returns the processing-speed metric: items_cataloged (items the pipeline produced across the team's completed jobs), processing_seconds (their total wall-clock completed_at - created_at), and seconds_per_item (the average, or null when there are none yet). Jobs still running, or completed but with no items, are excluded so they cannot distort the per-item average. | get_current_user, team-scoped |
training
The training program (see Training and certification).
| Endpoint | Purpose |
|---|---|
GET /api/training/overview | Missions with the caller's progress, points, badges, training claim id, certificate state. Quiz answers are never included. |
POST /api/training/start | Create (or return) the caller's sandbox training claim. Idempotent. |
POST /api/training/missions/{key}/quiz | Grade a quiz submission; stores the best score; returns per-question results and explanations. |
GET /api/training/missions/{key}/checks | Side-effect-free grading of the mission's doing-checks, safe to poll. Powers the on-screen task tracker. |
POST /api/training/missions/{key}/check | Grade the mission's doing-checks against the training claim's real state; completes the mission and banks points when everything passes. |
GET /api/training/leaderboard | Team-scoped points standings. |
POST /api/training/certificate | Issue the caller's certificate once all missions are complete (409 before that). |
GET /api/training/certificates/{code} | Public, no auth: verify a shared certificate code. Returns {valid: false} for unknown or revoked codes. |
Training claims also change the behavior of three existing endpoints: uploads into them complete instantly with fixture items (no pipeline, no credits), GET /api/jobs/{id}/quote returns 0 credits, and POST /api/claims/{id}/submit-to-adjust-square performs a dry run that never contacts Adjust Square.
Admin dashboard (/api/admin)
The internal operator dashboard. Unlike every other router, these endpoints aggregate across all teams (no team scoping) and are gated by require_admin, so only the Adjust Square user IDs in ADMIN_EXTERNAL_USER_IDS (default {7, 10}) can reach them. A non-admin gets 403 "This page is restricted."; an unauthenticated request gets 401.
| Method & path | Purpose | Auth |
|---|---|---|
GET /api/admin/overview?range=<range> | One aggregate payload for the whole dashboard: headline credit spend, system counts (claims, items, videos, audios, photo requests), credit spend by media type, a per-team usage leaderboard (claims / videos / audios / photo requests / items / credits), the latest claims, the last 10 jobs (any status), jobs processing now, recently failed jobs (with error messages), the recent activity feed, recent Adjust Square submissions, and aggregate quality metrics (avg items/claim, avg credits/claim, avg processing time per request). The stats include ai_cost_usd (our OpenRouter spend in dollars within the range, from the persisted per-job ai_cost_usd) and two live figures that ignore the range: active_users (users seen in the last 5 minutes via users.last_seen_at) and active_jobs. All customer spend is in credits, always whole numbers. | require_admin |
GET /api/admin/users?search=<q> | List users (id, external id, name, email, team, is_admin) for the impersonation picker, optionally filtered by name or email. Capped at 200. | require_admin |
POST /api/admin/impersonate/{user_id} | Mint a short-lived token that authenticates as that user so an admin can see the app exactly as they do. Returns {token, user}; the frontend swaps tokens and can switch back. Carries an impersonated_by audit claim and writes a non-restorable impersonate_user activity entry. Returns 400 if the user has no Adjust Square id. | require_admin |
GET /api/admin/active-jobs | Just the live count of jobs processing right now: { "active_jobs": <int> }. The same number as the overview's active_jobs stat (it shares one query), but cheap enough to poll on a short interval. The dashboard polls it to drive a favicon job-count badge, so an operator with /admin pinned can see at a glance when jobs suddenly start running. | require_admin |
GET /api/admin/logs | Every processing job system-wide, newest first, each tagged with its claim, team, and creating user, and carrying its log entries (live in-memory buffer while running, the job_log_archives copy once finished). Filters: team_id, user_id, status (a job status or active for anything still processing), q (substring on filename or claim number). Paginates with limit/offset and returns total plus the full teams and users lists for the filter dropdowns. Backs the Logs page (/logs). | require_admin |
GET /api/admin/activity | The activity log across all teams, newest first, each row tagged with its team (the customer GET /api/activity scopes to the caller's team). Filters: team_id, q (substring on user, action, or target); paginates with limit/offset. Restore stays team-scoped through the existing POST /api/activity/{id}/restore, so the dashboard only offers Restore on rows from the admin's own team. | require_admin |
GET /api/admin/media | Browse every uploaded media item system-wide (one row per processing job), newest first, tagged with team, user, and claim. Each item carries directly renderable URLs: the video/audio capability stream, a thumbnail (first extracted frame or first photo), and photo jobs list their uploaded photos (capped at 12, photo_count has the total). file_exists flips to false once the original file was cleaned off disk. Filters: type (video/photo/audio), team_id, q (filename or claim number); paginates with limit/offset. Backs the admin Media tab. | require_admin |
GET /api/admin/components | Every toggleable UI component (the registry in services/component_visibility.py) with its per-component default, global state, and per-team overrides. Resolution: team override > global override > registry default. Training's default is HIDDEN: it is enabled from the admin Teams tab. | require_admin |
PUT /api/admin/components | Set or clear one visibility override: {component, team_id, visible}. team_id: "" targets the global row; visible: null clears the override so the level above applies. Returns the updated table. What a user's team resolves to is delivered as features on GET /api/auth/me. | require_admin |
Impersonation lets an operator debug a customer's exact view. The endpoint signs a valid AdjustSquare-format token for the target user with the shared ADJUSTSQUARE_JWT_SECRET, so it authenticates as them on every subsequent request; the token is short-lived and carries an impersonated_by claim for audit. The admin's own token is kept client-side so they can switch back, and an app-wide banner makes the impersonation obvious. Every impersonation is recorded in the activity log.
The optional range query param scopes every time-bounded figure to one window: today, 24h, week, 30d, year, or all (the default; an unrecognized value falls back to all). Two figures intentionally ignore the range because they describe current state: active_jobs and the "jobs processing now" list are always live. Both also exclude photo jobs in awaiting_grouping (those wait on a user action, not on our processing). "Photo requests" counts billed photo requests (the sum of photo_request_count), not raw photos, and the per-request job time normalizes each photo batch's wall-clock by its request count so batches of different sizes are comparable.
See authentication for the allowlist and gating, and billing for what "credits" means.
health
| Method & path | Purpose |
|---|---|
GET /health | Liveness check (no /api prefix). Returns {status, model}; used by the Docker healthcheck. |
Next
- Authentication: how the JWT becomes a user and team.
- Adjust Square: the submit endpoint in depth.
- Frontend: how the client calls all of this.
| GET /api/admin/api-requests | Outbound AI API calls (OpenRouter) grouped by job, newest activity first. Each group carries per-job totals (request count, cost, time, tokens), the owning claim/team/user, and its full request list (request JSON with base64 images redacted, full response JSON, attempts, duration, real cost from usage accounting). Filters: q (job id / claim number / filename), team_id, user_id; limit/offset paginate the groups. Backs the "API Requests" tab on the Logs page. | require_admin |
| POST /api/admin/jobs/{id}/dismiss | Mark a hung/abandoned job as failed so it stops counting as active: sets a retryable error message, refunds any up-front charge (idempotent), archives its in-memory logs. 409 for jobs already finished. | require_admin |
| DELETE /api/admin/jobs/{id} | Hard-delete a job and everything beneath it: items (and their photos), staged photos, archived logs, and files on disk. The AI API request log rows are kept as the cost accounting record. Cannot be undone. | require_admin |