Plan import lets a coach turn an existing plan into a Perform plan. The coach pastes text or uploads photos and PDFs, the backend reads and structures the document, matches every food or exercise against the studio’s library, and returns a review payload. The web wizard shows that review, the coach fixes what needs fixing, and the wizard then builds the plan and saves it through the normal program templates or programs routes. This module does not create a plan. It produces the review, and it creates the new library items the coach approved. The module does not follow the five-file pattern. It has routes, controller, service and schema files, no repository (the service uses Prisma directly), and six pipeline files:

How a job runs

analyze answers 202 at once and runs the job in the same process with setImmediate. It does not use BullMQ. Progress is written to one Redis key, perform:plan-import:<jobId>, with a one hour expiry. The client polls the job route. Things to know:
  • Because the job runs in process, it does not survive a restart. A deploy or crash mid-job leaves the Redis state stuck below 100 with done: false until it expires. The client should give up after a reasonable wait.
  • An empty structured result is kept. A document of generic prose does not fall through to line-by-line parsing. It ends with the “nothing found” error.
  • The model never matches and never invents values. Every value in the structurer’s output schema is a string, and an empty string means “not written in the document”.
  • Without ANTHROPIC_API_KEY, both the OCR and the structurer are null. Text-only imports still work end to end with the deterministic parser. File uploads fail with a Hebrew message telling the coach to paste the plan as text.
The model is Anthropic, configured from ANTHROPIC_API_KEY, AGENT_MODEL and the optional ANTHROPIC_WORKSPACE_ID.

Matching

matcher.ts scores a name against every library row with token Jaccard similarity, after removing stop words and applying a synonym table. Two overrides apply: an identical normalized name scores 1, and a name that covers all of a library row’s tokens scores at least 0.85. A match needs a score of 0.5 or more. Up to four candidates are returned, best first. A library row answers to several names and scores the best of them: the studio’s override name, the library name, the English name, and, for exercises, the hidden search aliases. The matching universe is the shared rows plus the studio’s own, with plan-only rows excluded. The displayed confidence is 0 to 100: 99 for an exact name, 95 for identical tokens, at least 85 for a subset match, otherwise the rounded score.

Endpoints

POST /v1/web/plan-import/analyze

Starts an analyze job. Returns 202. Auth: web lane, coach roles.
string
required
nutrition or training.
string
Pasted plan text. Maximum 20,000 characters.
object[]
Up to 10 files.
string
required
1 to 300 characters.
string
required
One of image/jpeg, image/png, image/webp, application/pdf.
string
required
Base64. The decoded size, estimated as 0.75 of the string length, must be at most 8 MB.
Non-blank text or at least one file is required. This path has its own JSON body parser in app.ts with a 48mb limit, above the 24mb global limit, because a request can carry ten base64 files. The job state stores the caller’s studioId and userId as its owner. Response:
Errors: VALIDATION (422) for text or at least one file is required, file exceeds 8MB, more than 10 files, or an unsupported media type. BAD_REQUEST with status 413 when the body exceeds 48mb. FORBIDDEN, UNAUTHORIZED. Failures inside the job do not surface here. They appear in the job’s error field.

GET /v1/web/plan-import/analyze/:jobId

Returns the job’s progress, and the review once it is done. Auth: web lane, coach roles. Only the user who started the job can read it.
string
required
The id returned by analyze.
A job id is not a capability. The stored owner must match the caller’s studio and user. An expired job, another studio’s job and another user’s job all answer the same NOT_FOUND. Response while running:
Response on failure: the request itself succeeds with 200. done is true and error holds a Hebrew message that is safe to show the coach.
There are three messages: file analysis is unavailable (no OCR configured), no plan was recognized in the document, and a generic “analysis failed, try again”. Response when done: stage is review, pct is 100, and review is one of the two payloads below. Errors: NOT_FOUND (analyze job not found).

POST /v1/web/plan-import/commit-items

Creates the new foods or exercises the coach approved in the review, and returns their database ids. Auth: web lane, coach roles.
string
required
nutrition or training.
object[]
Up to 200 new foods.
object[]
Up to 200 new exercises.
Each food:
string
required
The registry key from the review. Echoed back as the key of the result map.
string
required
1 to 200 characters. Trimmed on save.
string[]
required
At least one of the 20 food category slugs. The first is stored as the home category.
object
required
kind is g or piece. label is up to 40 characters.
object
required
kcal, protein, carbs, fat as strings of up to 20 characters. An empty string means the document carried no value.
boolean
default:"true"
false creates the food for this plan only.
Each exercise:
string
required
The registry key from the review.
string
required
1 to 200 characters.
string
required
strength or aerobic.
string[]
required
At most one muscle slug.
string[]
required
At most 12 muscle slugs.
string
Up to 60 characters. A Hebrew label from the review is mapped to its slug (barbell, dumbbell, cable, machine, body weight, smith machine). Any other value is stored as sent.
boolean
default:"true"
false creates the exercise for this plan only.
All rows are created in one transaction as studio-owned rows with isSeeded: false. If any insert fails, nothing is created. How a food is stored (foodCreateData): How an exercise is stored (exerciseCreateData): bodyParts is derived from the target muscle through BODY_PART_BY_MUSCLE, exerciseTypes through categoriesFor, and searchText is built from the name. The source column records where the row came from: Response: a map from each registry key to the new row’s id. The wizard uses it to replace keys with real ids when it builds the plan content.
The key format in the example is illustrative. Keys are whatever the review’s registry used. With no foods and no exercises, ids is an empty object. Errors: VALIDATION, FORBIDDEN, UNAUTHORIZED.

POST /v1/web/plan-import/rollback-items

Deletes items created by commit-items, for when the coach abandons the import after committing. Returns 204 with no body. Auth: web lane, coach roles.
string
required
nutrition deletes foods. training deletes exercises.
string[]
required
Between 1 and 400 row ids.
The delete is scoped hard. A row is removed only when all three hold: its id is in the list, it belongs to the caller’s studio, and its source is ai or ai-private. Shared rows, other studios’ rows and manually created rows can never be deleted through this route. Ids that do not qualify are ignored without an error. The route does not check whether a plan still references a row. Errors: VALIDATION, FORBIDDEN, UNAUTHORIZED.

The review payload

The ImportReview shapes are a locked contract between the backend and the web wizard. They are defined as TypeScript types in plan-import.schema.ts. In both shapes a registry maps a key to a food or exercise, and the plan structure refers to registry entries by key. That way one food mentioned in three meals is reviewed once. Empty strings are meaningful throughout: they mean the document did not carry that value.

Nutrition review

Names, labels and source lines are in the document’s language, normally Hebrew. The example uses English for readability. For an exchange table, each row becomes one item: the first alternative is the main food, the rest are alts, and the quantity is the number of portions times the per-portion quantity. Code does this conversion, not the model. A registry food (RegFood):

Training review

How imported plans are stored

The wizard builds plan content from the review and saves it through the template or program routes. It marks the content with meta.imported: true, plus meta.source (a label such as the number of files) and meta.draft. While imported is true, the content normalizers keep empty values as they are and do not fill builder defaults, because an empty value is the “missing in the document” marker that drives the review highlights. The details are under “Imported content” on the Program templates page. That marking is done by the web app. This module’s code only produces the review and the library rows.