Day 4 — Prescriptions & Stock
Version: 1.0 | Status: written against code 2026-09-16
Yesterday you followed money: cart, order, payment. Today you follow the two things that make a pharmacy a pharmacy. The first is the prescription: a photo that a patient or kiosk uploads, that an AI may read, that a pharmacist reviews, and that becomes a cart. The second is stock: the batches on the shelf, which ones leave first, and the record of every unit that moves.
Most of today's code runs in the background. Four sweepers and one worker pool keep prescriptions moving when a request fails partway or a deploy kills a task. Most of the bugs in this area came from background work, so most of the traps do too.
Goals
By the end of today you can:
- Explain the three layers of a prescription (submission, page, review) and the statuses each one moves through.
- Trace an upload from presigned PUT to review queue, and say which sweeper recovers each step if it fails.
- Explain how the Gemini extraction pool is bounded, why it uses a claim token, and what happens to queued jobs at shutdown.
- Explain resolve-on-fetch: why a pharmacist's saved cart only reaches the patient's real cart when the patient opens the app.
- Read the inventory model (product → batch → product_variant → stock_movement) and explain FEFO allocation and reservation at order placement.
- Say what
internal/hospitalcatalogandinternal/dashboardcompute, and which panel uses them. - Explain how patient phone lookups work when the phone column is encrypted (blind indexes).
Reading order
| # | File | What to look for |
|---|---|---|
| 1 | medyzen-backend/internal/prescription/module.go | Wiring: one service, one review service, four sweepers, one extraction service. The sweeper intervals (all 5 min). |
| 2 | medyzen-backend/internal/prescription/models/submission.go, prescription.go, review.go, extraction.go | The status constants. MaxSubmissionPages = 10, SubmissionStaleWindow = 6h, LockTTL = 30s. |
| 3 | medyzen-backend/internal/prescription/routes/routes.go, review_routes.go | The public surface: submissions, upload-url, confirm, patient-view, download-url, reviews, extract. |
| 4 | medyzen-backend/internal/prescription/services/prescription.go | CreateSubmission, CreateUpload, Confirm, Resubmit, toPatientView, DownloadURL. |
| 5 | medyzen-backend/internal/pkg/storage/r2.go | PresignPut, PresignGet, HeadObject, and the comment above Upload (lines 60–74) about public URLs. |
| 6 | medyzen-backend/internal/prescription/services/review_statemachine.go | The whole review state machine in 27 lines. |
| 7 | medyzen-backend/internal/prescription/services/review.go | Create, AutoCreateForPrescription, GetPageContent, Reject. Push now returns ErrReviewEndpointGone. |
| 8 | medyzen-backend/internal/prescription/services/review_save_resolve.go | Claim/Release (the edit lock), Save (Schedule H check), ResolveSavedCartsForPatient (resolve-on-fetch). |
| 9 | medyzen-backend/internal/prescription/services/extraction.go | Read the doc comments as carefully as the code. Pool size, queue capacity, stale threshold, reclaimStale, claim, writeReady. |
| 10 | medyzen-backend/internal/pkg/genai/gemini.go | The prompt, the 8 MB image cap, temperature 0, JSON response. |
| 11 | medyzen-backend/internal/prescription/services/stale_extraction_sweeper.go, submission_reaper.go, review_autoenqueue_sweeper.go, review_push_sweeper.go | The four sweepers. |
| 12 | medyzen-backend/internal/pkg/worker/supervise.go + cmd/server/main.go lines ~255–280 and ~700–865 | How sweepers get restarted after a panic, and the shutdown order. |
| 13 | medyzen-backend/internal/pkg/pii/pii.go + internal/database/migrations/253_patient_pii_ciphertext_columns.up.sql | Encryption and blind indexes. |
| 14 | medyzen-backend/internal/inventory/models/*.go | Product, Batch, ProductVariant, StockMovement, StockCount. |
| 15 | medyzen-backend/internal/inventory/services/stock.go + repositories/product_variant.go (ListAvailableByProduct) | Allocate, Reserve, Release. |
| 16 | medyzen-backend/internal/hospitalcatalog/services/medicines.go + repositories/medicines.go | The hospital panel's "medicines" page. |
| 17 | medyzen-backend/internal/dashboard/services/dashboard.go, warehouse_summary.go | The two dashboard summaries. |
Explanation
1. The shape of a prescription: three layers
A single "prescription" in the product is really three kinds of row:
| Layer | Table | Model | What it is |
|---|---|---|---|
| Submission | prescription_submissions | PrescriptionSubmission | One upload event, 1–10 pages. It records page_count, stale_after and source (patient_app, whatsapp, kiosk). |
| Page | prescriptions | Prescription | One image or PDF in R2. It has a storage_key, page_index and its own status. Page 0 is the anchor page. |
| Review | prescription_reviews + prescription_review_items | PrescriptionReview | A pharmacist's work on the submission: the lines they picked, substitutions, and the prescriber snapshot. |
There are two more tables. prescription_extractions holds the AI reading of one page. prescription_review_pushes is an outbox of notifications to the patient.
Why split submission from page? A patient photographs a two-page prescription, and page 2 fails to upload on a flaky connection. The submission stays collecting until every page is confirmed. Only then does it become complete and join the review queue. A pharmacist never sees half a prescription.
1b. The endpoints at a glance
Every route below is wrapped in middleware.Auth. The patient-facing routes also get ForbidKioskHeaderForEntityType(EntityUser), which stops an app user from sending an X-Kiosk-Id header to act as a kiosk.
Method + path (prefix /prescription/api/v1) | Handler | Who calls it |
|---|---|---|
POST /prescriptions/submissions | CreateSubmission | App, kiosk |
POST /prescriptions/submissions/resubmit | Resubmit | App |
POST /prescriptions/upload-url | CreateUpload | App, kiosk |
POST /prescriptions/{docId}/confirm | Confirm | App, kiosk |
GET /prescriptions | List (page-level) | App |
GET /prescriptions/patient-view | PatientView (submission-level, patient statuses) | App |
GET /prescriptions/{docId}/download-url | Download (presigned GET, audited) | App |
POST /prescriptions/{docId}/extract, GET …/extraction | Extraction | Kiosk operator only |
POST /reviews, GET /reviews, GET /reviews/{id} | Review create/list/detail | Warehouse panel |
POST /reviews/{id}/claim, /release | Edit lock | Warehouse panel |
POST /reviews/{id}/save, /reject | Decide | Warehouse panel |
POST /reviews/{id}/push | Gone (ErrReviewEndpointGone) | Nobody |
GET /reviews/{id}/pages/{docId}/content | Proxied page bytes | Warehouse panel |
The handler helper resolve (handlers/prescription.go) works out the patient and optional kiosk. With no patient header, the patient is the caller. When a patient header (acting.PatientHeader) is present, X-Kiosk-Id becomes mandatory, and acting.Resolve checks that the actor is allowed to act for that patient at that kiosk. Day 2 covered acting-on-behalf.
The shared route prefix is why routes/routes_test.go registers all three route groups on one mux. With Go 1.22 pattern routing, two ambiguous patterns panic only when both are mounted.
2. Upload: presigned PUT, then confirm
The API server never receives the image bytes. The flow in services/prescription.go:
Details worth knowing:
- Content types are limited to
image/jpeg,image/pngandapplication/pdf(allowedContentTypes,prescription.go:33). - One page slot can be claimed only once. A unique violation on insert becomes
ErrSubmissionPageAlreadyClaimed(prescription.go:239). - Confirm does not trust the client. It calls
HeadObject. If the object is missing, confirm returnsErrPrescriptionNotUploaded. If it is overmaxPrescriptionPageBytes(25 MB), confirm deletes the object and returnsErrPrescriptionPageTooLarge(prescription.go:421–443). - Completion is a conditional UPDATE, not a read-then-write.
TryComplete(repositories/submission.go:59) flipscollecting → completeonly whenpage_countequals the count ofuploadedpages, and it reports whether this call won. Two pages confirming at the same moment cannot both enqueue a review. - Ownership of
order_id. When an app user attaches an order,assertOrderBelongsToPatientchecks that the order is theirs. If not, it returns "order not found", so the caller learns nothing about other orders. - No submission_id means an implicit one-page submission is created (
prescription.go:190–210). This is the legacy single-page path. - Resubmit (
prescription.go:259) builds a brand-newcompletesubmission. The new page rows reuse the oldstorage_keys, so no bytes are copied. It refuses if a source submission still has an open review.
3. Statuses: one state diagram per layer
Page status (models/prescription.go) has three values. Submission status (models/submission.go) has three values. Review status (models/review.go, services/review_statemachine.go) is the real state machine:
Three things the diagram does not show:
cart_pushedstill exists as a constant, but it has no edges. It is legacy from the old "pharmacist pushes cart" flow.review_statemachine_matrix_test.gohasTestCanTransitionReview_CartPushedIsFrozenLegacyAndHasNoEdges. ThePushendpoint now returnsErrReviewEndpointGone(review.go:426).patient_respondedandexpiredare allowed transitions, but a grep of non-test Go code finds no writer for either status. Treat them as reserved (not verified whether a later slice adds them).- The patient never sees these raw statuses.
toPatientView(prescription.go:569) maps them to the patient-facing set.collectingbecomespending_upload.abandonedbecomesincomplete.pending_reviewandcart_savedboth becomequeued.cart_resolvedbecomescart_pushed.cancelledbecomesrejected.
4. The review: queue, lock, save
Getting into the queue. A review is created in one of three ways:
ConfirmwinsTryCompleteand callsAutoCreateForPrescription(anchor)inline.Resubmitdoes the same for its new submission.- If the inline call fails (for example, a DB blip after the confirm transaction committed), the auto-enqueue sweeper finds it later (section 7).
AutoCreateForPrescription is idempotent. It returns nil if an open review exists, and it swallows a unique violation (review.go:258–294). This is what makes a sweeper retry safe.
The edit lock. Two pharmacists must not edit the same review. Claim (review_save_resolve.go:77) takes SELECT … FOR UPDATE on the review. It refuses with a 409 CodeConflictReviewLocked if someone else holds a live lock. A lock is live while heartbeat_at + 30s is in the future (models.LockTTL). There is no lock sweeper: a stale lock simply stops counting as live. The client re-claims to heartbeat. clock.DBNow() truncates to microseconds so that a renew does not look like the lock moved (see the comment at line 94).
Who may act. requireWarehouseActor allows only the pharmacist, warehouse_manager, store_manager and store_staff roles, and they must have a warehouse scope. Substitutions need the pharmacist role (ErrReviewSubstitutionRequiresPharmacist).
Save (review_save_resolve.go:187) validates the items. Every included line needs an active product. Every missing line needs a reason. Save then checks the Schedule H rule (section 9). In one transaction it replaces the items, stores the prescriber snapshot, moves pending_review → cart_saved, and inserts a prescription_review_pushes row. After the commit it tries to send the notification inline. If the send fails, the push sweeper retries it.
Viewing a page. Pharmacists do not get a presigned URL. GetPageContent (review.go:369) streams the object through the API. It writes both an audit row (prescription.page_view) and a review_page_access_log row. This is how the system can answer "who looked at this patient's prescription".
5. Resolve-on-fetch
This is the least obvious design in the module. When the pharmacist saves, nothing is put into the patient's cart. The review sits at cart_saved. The cart is built later, when the patient opens it:
Why wait? The pharmacist's warehouse, and the stock on hand at save time, are not what matters. What matters is the warehouse that will deliver to the patient's address now, and what that warehouse has now. Resolving at fetch time uses the current pincode and current stock. It also picks backend ("tier 2") substitutes for lines that are out of stock, ranked by equivalenceRank: exact_salt, then same_salt_different_strength, then combination_equivalent.
Two failures do not break the cart. ErrNoDeliveryPossible and ErrPrescriptionWarehouseUnresolvable become HeldPrescription entries (review_save_resolve.go:371–400). The app can then say "add an address" instead of showing an error. Any other error fails the whole cart fetch.
The re-check if locked.Status != cart_saved { return nil } inside the transaction matters. Two concurrent cart fetches must not both push the same items.
Resolve-on-fetch only runs for channel == app (cart/services/cart.go:570). Kiosk carts do not use it.
6. Gemini extraction and the bounded pool
Extraction exists to pre-fill a cart at the kiosk. The routes POST /prescriptions/{docId}/extract and GET …/extraction need a hospital_operator actor and an X-Kiosk-Id header (handlers/extraction.go, services/extraction.go:227). Upload does not trigger extraction. The operator calls it explicitly. If GEMINI_API_KEY is empty, Enabled() is false and Start returns ErrExtractionUnavailable.
The Gemini client (pkg/genai/gemini.go) is small. It uses model gemini-2.0-flash unless GEMINI_MODEL is set, a 45 s HTTP timeout, temperature 0, ResponseMimeType: application/json, and images of at most 8 MB. The prompt says "Never invent a medicine" and "Never substitute a brand". Error strings never include the response body, so PHI stays out of logs.
The pool constants (services/extraction.go:31–74):
| Constant | Value | Why |
|---|---|---|
extractionPoolSize | 4 | At most 4 Gemini calls and 4 DB connections at once (out of DB_MAX_OPEN_CONNS=20). |
extractionQueueCapacity | 64 | Buffered channel. When it is full, the job is dropped and the row is marked failed immediately, so the operator can retry. |
extractionRunTimeout | 60 s | One job: R2 read + Gemini call + write-back. |
extractionStaleAfter | 20 min | Must exceed (64/4)×60 s + 60 s = 17 min, or a job that is legitimately queued looks stale. |
The claim token is the core idea. The API runs on several ECS tasks. The same row can be touched by a request on task A, a request on task B, and the sweeper. The token is the row's updated_at as Postgres stored it (read back with RETURNING, never a Go clock value). Every later write says WHERE updated_at = <token>:
claim()runs just before the Gemini call. If a reclaim or a sweep moved the row while the job waited in the queue, zero rows match and the job is dropped without calling Gemini. The comments explain why this matters: a duplicate call sends the patient's prescription image to a third party a second time. That is a PHI problem and a cost problem.writeReady/writeFailedcannot overwrite a newer attempt's result.reclaimStaleand the sweeper compute the cutoff withnow()inside SQL, so clock skew between tasks and the database cannot produce two winners.
writeReady encrypts the lines itself (extraction.go:452). This is necessary because a raw UPDATE does not run the GORM BeforeSave hook in models/extraction_pii.go.
7. The four sweepers
All four are built in module.go with a 5-minute interval and started in cmd/server/main.go:732–735:
| Worker name (main.go) | Type | What it finds | What it does | Why it exists |
|---|---|---|---|---|
prescription.review_sweeper | PrescriptionReviewPushSweeper (review_push_sweeper.go) | prescription_review_pushes with delivered_at IS NULL, next_attempt_at <= now, attempts < 5 | Claims up to 50 with FOR UPDATE SKIP LOCKED, bumps attempts and leases them for one interval, sends, then marks delivered, schedules a retry (1m / 5m / 30m) or marks exhausted | The inline send after Save/Resolve can fail; the outbox row guarantees a retry. |
prescription.auto_enqueue_sweeper | PrescriptionReviewAutoEnqueueSweeper | Anchor pages (page_index = 0, uploaded) of complete submissions with no non-deleted review (ListUnroutedUploaded) | AutoCreateForPrescription for up to 50 | Confirm commits first and enqueues after. A failure in between would otherwise strand the prescription forever. |
prescription.submission_reaper | PrescriptionSubmissionReaper (submission_reaper.go) | Submissions still collecting after stale_after (6 h) | MarkAbandoned (conditional on still collecting), then deletes each page's object from R2 | Half-uploaded prescriptions are health data nobody will use. Rows are kept; bytes are purged. |
prescription.stale_extraction_sweeper | StaleExtractionSweeper | Extractions processing for longer than 20 min | One UPDATE → failed, batch of 200, FOR UPDATE SKIP LOCKED | reclaimStale only runs when someone retries. Without the sweeper, an extraction killed by a deploy would show "processing" forever. |
Each tick uses context.WithTimeout(context.WithoutCancel(ctx), interval). Once a tick starts, a shutdown signal does not cut it off halfway. The loop checks ctx.Done() only between ticks.
8. Supervision and shutdown
worker.Supervise (pkg/worker/supervise.go) is a restart loop:
- It runs
start(ctx)with arecover(). A panic is logged with its stack and sent to Sentry, at most once per 5 minutes per worker name. - If the worker returns while
ctxis still live, it restarts after a backoff: 1 s, doubling, capped at 30 s. The backoff resets if the run lasted at least 2 minutes. - It returns only when
ctxis cancelled.
main.go uses two contexts, and the difference matters:
reconcilerCtxis cancelled bystopReconciler()the moment a signal arrives. The four sweepers run under it, so they stop taking new ticks right away.poolCtxis cancelled bystopPools()only afterserver.ShutdownandadminServer.Shutdownreturn. The extraction pool runs under it. A request that is still draining can callStartand enqueue a job, and a pool that was already stopped would never run that job.
poolWorker (extraction.go:170) has two selects. The first one takes a buffered job with a default case. Only when the channel is empty does the second select look at ctx.Done(). Go picks randomly among ready cases, so a single select could exit while jobs are still waiting. A job that has already started runs on context.WithoutCancel, bounded by its 60 s timeout.
There is one shared deadline (cfg.GracefulTimeout) for the whole sequence, because ECS sends SIGKILL after its own stop timeout (30 s in this deploy, per the comment at main.go:803). A job still buffered at SIGKILL is not lost for good. Its row stays processing, and the stale-extraction sweeper or the next reclaim recovers it.
9. Schedule H/H1 and prescription-only products
The enforcement is spread across layers. It is worth knowing exactly where each check lives.
| Check | Where | What it does |
|---|---|---|
| Product flags | inventory/models/product.go | requires_prescription bool and schedule (none, g, h, h1, x, narcotic). Two separate fields. |
| Patient OTC search hides Rx | inventory/repositories/search.go productSearchOTCSQL | AND p.requires_prescription = false. Route GET /inventory/api/v1/products/search/otc is app-only. |
| Patients cannot add or increase Rx lines | cart/services/cart.go:304 enforceQuantityCap | Called from updateAppCart and updateKioskCart. For any line that requires a prescription, a quantity above what is already in the cart returns ErrPrescriptionQuantityIncreaseNotAllowed. A new Rx product starts at 0, so adding it is refused. |
| Review lines can add Rx items | cart/services/cart.go:396 PushReviewItems | Products that came from the pharmacist's review are passed as exempt. This is the only path that puts an Rx line into a cart. |
| Prescriber required for H/H1 | prescription/services/review_save_resolve.go:217–231 + services/prescriber.go scheduleHRequiresPrescriber | If any included product's schedule is h or h1, Save needs a prescriber (ErrReviewPrescriberRequired). A registration number is required unless it is marked not legible. |
| Resolve re-checks, but only logs | review_save_resolve.go:419–435 | A saved review with an H line and no prescriber snapshot produces a warning, not a block. |
| Schedule H register | GET /inventory/api/v1/reports/schedule-h | Report over dispensed H items. The patient name is decrypted per row. |
Two things are not enforced in the code I read. scheduleHRequiresPrescriber matches only h and h1, not x or narcotic. And nothing validates the prescription's date or expiry. Whether that is compliant is a legal question for legal-compliance, not something this lesson decides.
10. Where files live and who can read them (R2)
- The key format is
prescriptions/<patient uuid>/<page uuid>.<ext>(prescription.go:213). Both UUIDs are exposed by the API. - Every read path in the code is presigned or proxied. Patient downloads use
PresignGetwith a 5-minute TTL. Order pages for staff usePagesForOrderwith a 5-minute TTL. Pharmacist review pages are streamed byGetPageContent. Uploads usePresignPutwith a 10-minute TTL. R2Client.Uploaddeliberately returns no URL (r2.go:60–74). It used to return a public-bucket URL. Anyone who knew the two UUIDs could then rebuild the URL and fetch the image without auth.- What the code cannot tell you: whether public read is still enabled on the bucket itself. That is a Cloudflare/Terraform setting. Team notes (2026-09) said document files were still publicly fetchable and that the fix needs bucket, API and KYC changes together. Not verified here. Check the bucket configuration before assuming the key alone is safe.
11. Patient PII: ciphertext and blind indexes
Migration 253 (internal/database/migrations/253_patient_pii_ciphertext_columns.up.sql) adds *_ciphertext columns for phone, email, name and address fields on users, patient_addresses and orders, plus prescription_extractions.lines_ciphertext. The rollout followed an expand/backfill/contract plan:
- Migration 253 adds the columns.
- The app deploy writes both.
server -encrypt-patient-pii -commitbackfills old rows (cmd/server/main.go:92–94; without-commitit is a dry run;pkg/pii/backfill.go).ops/pending-migrations/254_patient_drop_plaintext_piidrops the plaintext. It lives inops/, so it is applied by hand.
The models no longer map the plaintext columns. main.go:244–255 refuses to boot unless both PATIENT_ENCRYPTION_KEY and PATIENT_BLIND_INDEX_KEY are set.
How do you find a patient by phone when the phone is encrypted? AES-GCM ciphertext is randomized, so WHERE phone_ciphertext = ? can never match. Instead, a blind index is written next to the ciphertext: an HMAC-SHA256 of a normalized value under a separate key.
- Login / kiosk identity:
human/repositories/user.gophoneLookupcomputesPhoneIndex(e164)and queries<column>_bidx = ?. No cipher, or a value it cannot index, becomesWHERE FALSE. There is no plaintext fallback anymore. - Order search (
order/repositories/order.go:220–250) ORs together:order_no ILIKE,patient_name_bidx3(first 3 letters of the name),patient_phone_bidx10(10+ digits typed), orpatient_phone_bidx4(exactly 4 digits typed). - The prefixes (
phone:,phone4:,email:,name3:) keep the index spaces separate, so a 4-digit bucket cannot collide with a full-number index. - The trade-offs are written in
pii.go. The 3-letter name bucket means "Rav" and "Ravindra" return the same set. "Dr. Ravi" buckets asdrr.NamePrefixLencannot change without re-indexing every order. - The prescription AI lines are sealed as one blob, because nothing queries inside them (
models/extraction_pii.go).
12. Inventory: products, batches, variants, movements
productsis the global catalogue: name, composition, form, strength,schedule,requires_prescription, GST/HSN andcold_chain. Reads go through a 30-second in-process cache (repositories/product_cache.go). The cache is bypassed inside a transaction (database.HasTx).batchesare receipts at one warehouse (purchase,return,transfer), optionally linked to a purchase order (Day 5).product_variantsholds the stock. Each row is one product, in one batch, at one warehouse, with oneexpiry_date,quantity, MRP/purchase/selling price,rackand astatusofin_stock,out_of_stockorexpired. Stock is per warehouse and per batch.stock_movementsis the ledger:intake,adjustment,dispense,return,transfer_in/out,expiry_writeoff,return_writeoff,return_to_vendorandcount_adjustment, each withquantity_deltaandquantity_after.stock_countsis cycle counting.stock_count_items.varianceis a generated column (<-:false). Applying a count callsStockService.ApplyCountVariance.
Allocation is FEFO (first expiry, first out). ListAvailableByProduct returns active, in_stock, quantity > 0, not-expired variants ORDER BY expiry_date ASC. StockService.Allocate (services/stock.go:31) fills each request from those rows. It is a read: it locks nothing and changes nothing. The cart calls it to price lines and find shortfalls.
Reservation happens at order placement. OrderService.PlaceFromCart and CreateWalkIn call stock.Reserve inside the order transaction (order/services/order.go:251, :354). Reserve locks each variant with SELECT … FOR UPDATE, checks the warehouse and quantity, decrements, sets out_of_stock at 0, and writes a dispense movement. There is no separate "reserved" count. Placing the order takes the units off the shelf. Release does the opposite on cancel or return. With restock=false it writes a return_writeoff movement with delta 0, so the quantity is not restored.
13. internal/hospitalcatalog
One endpoint: GET /hospitalcatalog/api/v1/medicines, used by the Hospital panel. The caller's hospital comes from HospitalScope. For that hospital, MedicinesService.List (services/medicines.go) builds one row per product ever ordered through the hospital, with:
- ordered quantity, order count, fulfilled count and revenue (revenue counts only
delivered,completedandpartially_returnedorders); - times prescribed: reviews from the hospital's kiosks, not cancelled or soft-deleted, excluding substitute lines (
substituted_for_product_id IS NULL); - availability across the hospital's linked warehouses (
hospital_warehouses), bucketednot_stocked/out_of_stock/low_stock(<20) /in_stock; - a summary with active procurement requests (
pending,approved).
14. internal/dashboard
Two endpoints for two panels:
| Endpoint | Panel | Scope | Computes |
|---|---|---|---|
GET /dashboard/api/v1/summary | Hospital | HospitalScope | Total revenue and this-month revenue, both taken from hospital_payouts.fee_amount (not voided), not order totals. Also total orders and average order value (orders in recognized statuses), each compared with the previous period. Plus revenue and orders trends zero-filled per bucket, most prescribed / most ordered / top revenue medicines, and payment-mode breakdown. |
GET /dashboard/api/v1/warehouse-summary | Warehouse (super_admin may pass warehouse_id) | WarehouseAccess.Scope | Today's and this month's revenue (payment status paid or later), today's orders, active orders (a raw count), today's deliveries, average delivery minutes (null, not zero, when there are none), per-hospital performance, hourly orders 9–21, daily orders this month, top medicines, revenue trend, payment modes. |
All period boundaries are in IST (services/ist.go, AT TIME ZONE 'Asia/Kolkata' in SQL). A UTC midnight would move orders placed between 00:00 and 05:30 IST into the previous day.
Separately, internal/inventory has its own warehouse stock overview at GET /inventory/api/v1/overview: near-expiry, expiry buckets, top low stock and recent activity (inventory/services/dashboard.go).
Traps
R2 privacy depends on the bucket, not just the code. The code only presigns or proxies. But the key is guessable from API-visible UUIDs, and whether the bucket allows public read is not visible in Go. Never add a code path that builds
https://<bucket>/<storage_key>. Before you claim prescriptions are private, verify the bucket setting.Raw SQL skips GORM hooks, so it skips encryption.
BeforeSavesealslines. Adb.Exec("UPDATE prescription_extractions SET lines = …")does not.writeReadyseals by hand for exactly this reason. The same applies toorders,usersandpatient_addresses: any raw write of a PII field must also write_ciphertextand every matching_bidxcolumn (seesealedPhoneColumnsinhuman/repositories/user.go). If you forget the blind index, the patient can no longer log in.Blind indexes match exactly, after normalization. You cannot
LIKEa blind index. Phone search works only on 10+ digits or exactly 4 digits (order.go:238–249). Five digits match nothing exceptorder_no. A new lookup must use the sameNormalize*function and the same prefix, or it silently finds nobody.Keys are mandatory, including locally. Without
PATIENT_ENCRYPTION_KEYandPATIENT_BLIND_INDEX_KEY, the server exits at boot.docker-compose.ymldoes not set them, somake docker-upwill not start the API as-is. Use throwaway local keys. Never copy real ones.prescription_reviews.patient_nameis plaintext. Reviews copy the patient's full name into the review row (review.go:180–192). Migration 253 does not list this column, and it is only masked in DTOs (maskPatientName). Not verified whether a later migration seals it. Treat it as unencrypted PII.Sweeper claims must use
SKIP LOCKEDor a conditional UPDATE. Every task runs every sweeper. The push sweeper and stale-extraction sweeper useFOR UPDATE SKIP LOCKED. The reaper relies onMarkAbandoned … WHERE status = 'collecting'. The auto-enqueue sweeper relies on a unique index plusExistsOpenForSubmission. If you write a new sweeper with plain select-then-update, two tasks will process the same row.The push sweeper can double-notify. If the transport succeeds but
MarkDeliveredfails, the next tick sends again. The comment atreview_push_sweeper.go:100relies on the transport's idempotency key. When you add a transport, it must honourIdempotencyKey.Worker pools must drain on shutdown, in the right order. Cancel the pool context after HTTP shutdown, not at the signal. Give workers a "buffered job first"
select. Run the job oncontext.WithoutCancel. Wait onDone()inside the one shared deadline. Swap any of these and extraction jobs accepted during the drain are dropped silently. Any new pool must also join theworkerswait set inmain.go.Changing pool numbers can break the stale threshold. If you raise
extractionQueueCapacityor lowerextractionPoolSizewithout raisingextractionStaleAfter, legitimately queued jobs get reclaimed, and Gemini is called twice with the same patient image.extraction_threshold_test.go(TestExtractionStaleAfterExceedsWorstCaseQueueWaitPlusRunTimeout) guards this. The source comment calls the fileextraction_threshold_invariant_test.go, but the file on disk isextraction_threshold_test.go.Size limits do not line up. Confirm allows 25 MB per page. Extraction reads at most 12 MB. Gemini rejects anything over 8 MB. A 10 MB photo uploads fine and then fails extraction with "Could not read this prescription automatically". This is not a bug in extraction. It is the limits.
Use the database clock for anything compared in SQL.
reclaimStale,claimand the sweeper all usenow()in SQL, nevertime.Now().Claimusesclock.DBNow()(microsecond-truncated). Postgres stores microseconds. A Go nanosecond timestamp compared for equality with a stored one never matches, so a claim token built from Go time would never win.Resolve-on-fetch runs on a GET.
GET cartwrites to the cart, reviews and pushes tables, and may call the push transport. Do not cache that endpoint, do not call it from health checks, and remember it only runs for theappchannel.Schedule H needs both flags set correctly. Hiding a product from patients depends on
requires_prescription. The prescriber requirement depends onschedule IN ('h','h1'). A product withschedule = 'h1'butrequires_prescription = falseappears in OTC search and can be added freely. Catalogue data quality is part of compliance.Stock availability queries do not all agree.
ListAvailableByProductexcludes expired batches (expiry_date > now).hospitalcatalogAvailableStockdoes not filter on expiry.ProductVariantService.Adjustsetsstatus = in_stockfor any non-zero quantity, even on a variant markedexpired. When numbers disagree between panels, check which query each one uses.
Exercises
All of these run on your machine only. Never point anything at the production database or buckets. Blank every real secret in .env first (config.Load() reads it).
Setup (once).
cd medyzen-backend
docker compose up -d db
createdb -h localhost -p 5434 -U postgres medyzen_test
export DATABASE_URL="postgres://postgres:postgres@localhost:5434/medyzen?sslmode=disable"
export PATIENT_ENCRYPTION_KEY=$(openssl rand -hex 32)
export PATIENT_BLIND_INDEX_KEY=$(openssl rand -hex 32)
export GEMINI_API_KEY=""
go run ./cmd/server -migrate upState machine by test. Run
go test ./internal/prescription/services -run 'TestCanTransitionReview|TestAllowedReviewTransitions' -v. Then, on a local branch, temporarily addcart_saved → pending_reviewtoallowedReviewTransitionsand run the tests again. Which test fails, and why is that edge dangerous? Revert.Supervisor behaviour. Run
go test ./internal/pkg/worker -v. ReadTestSupervise_RestartsAfterPanicAndReturnsCleanlyOnCancel. Explain in two sentences why a sweeper that returnsnilearly whilectxis live gets restarted, but one that returns after cancellation does not.Claim token under a real database. Run the extraction integration tests against the scratch database:
bashTEST_DATABASE_URL="postgres://postgres:postgres@localhost:5434/medyzen_test?sslmode=disable" \ go test -tags integration ./internal/prescription/services -run 'Reclaim|StaleExtraction' -vFind the test named in the
reclaimStaledoc comment (TestReclaimStale_ClockSkewedWinnerCannotBeReReclaimed) and describe the race it prevents.Blind index by hand. In a scratch test file (do not commit), build a cipher with
pii.Newand your two local keys. PrintPhoneIndex("+91 98765 43210"),PhoneIndex("9876543210")andPhoneLast4Index("3210"). Confirm the first two are equal. Explain whyPhoneIndex("98765")returns"".Watch the sweeper SQL. Start the server locally with
GEMINI_API_KEYempty. Inpsqlon port 5434, insert aprescription_submissionsrow withstatus='collecting'andstale_after = now() - interval '1 hour'. Wait for the next reaper tick (up to 5 min) and confirm it becomesabandoned. R2 is unconfigured locally, sopurgePagesreturns early. Point out the line where that happens.FEFO. In local
psql, create one product and twoproduct_variantsin the same warehouse with differentexpiry_dates. CallStockService.Allocatefrom a scratch test (or readListAvailableByProduct) and predict which variant is taken first. Add a third variant that has already expired, and confirm it is ignored.Shutdown ordering. Read
main.gofrom the signal tosqlDB.Close(). Write the ordered list of steps in your notes. Then answer: ifstopPools()moved to just afterstopReconciler(), what exact failure would a kiosk operator see during a deploy?
Self-check
- What are the three layers of a prescription, and which one does a pharmacist review?
- What makes
TryCompletesafe when two pages confirm at the same moment? - The pharmacist saves a review. When does the patient's cart actually change, and why is it designed that way?
- Name the four prescription sweepers and, for each, the failure it recovers from.
- What is the extraction "claim token", where does its value come from, and what does it prevent?
- Why is
extractionStaleAfter20 minutes rather than 5? - On shutdown, why is
poolCtxcancelled afterserver.Shutdown, butreconcilerCtxcancelled immediately? - How does the login code find a user by phone number when the phone column is encrypted?
- Where in the code is a patient stopped from adding a prescription-only medicine to their own cart?
- Does the code ever hand out a public R2 URL for a prescription? What is still unverified?
- At which moment does stock leave
product_variants.quantityfor an order, and which movement type is recorded? - The hospital dashboard's "Total revenue" — which table does it sum?
Answers
- Submission (
prescription_submissions), page (prescriptions), and review (prescription_reviews+ items). The review is attached to the submission, through its anchor page (page_index = 0), so the pharmacist reviews all pages together. - It is a single conditional
UPDATE … WHERE status = 'collecting' AND page_count = (SELECT count(*) … status = 'uploaded'), and it returnsRowsAffected == 1. Only one caller can flip the row, so only that caller auto-enqueues the review. - Only when the patient fetches their app cart.
CartService.GetActivecallsResolveSavedCartsForPatient, which movescart_saved → cart_resolved. It resolves against the patient's current delivery warehouse and current stock, picking substitutes if needed, instead of whatever was true at save time. review_sweeper: push notifications that failed or were never sent.auto_enqueue_sweeper: complete submissions that never got a review because the inline enqueue failed after confirm.submission_reaper: submissions stuckcollectingafter 6 h (marks them abandoned, deletes R2 bytes).stale_extraction_sweeper: extractions stuckprocessingfor over 20 min, for example after a deploy killed the task.- It is the
prescription_extractions.updated_atvalue that Postgres returned (RETURNING) when the row was set toprocessing.claim,writeReadyandwriteFailedall requireupdated_at = token. It prevents a job that was superseded while queued from calling Gemini (a second PHI send), and it stops a late result from overwriting a newer one. - A job can legitimately wait behind a full queue: (64 / 4) × 60 s, plus its own 60 s run, is 17 min. A shorter threshold would reclaim jobs that are still validly queued and cause duplicate Gemini calls.
- The sweepers have no in-flight request depending on them, so they can stop at once. The extraction pool must keep accepting and running jobs that draining HTTP requests enqueue. Cancelling it first would drop that work.
poolWorkeralso drains buffered jobs before honouring cancellation. - It normalizes the phone to its last 10 digits and computes
HMAC(blind-index key, "phone:" + digits). Then it queriesphone_e164_bidx = ?(human/repositories/user.gophoneLookup). There is no plaintext fallback. With no cipher, the query becomesWHERE FALSE. - Two places.
SearchInStockOTCnever shows products withrequires_prescription = true.enforceQuantityCap(cart/services/cart.go:304) rejects any increase, including from zero, of an Rx line. OnlyPushReviewItems(lines from a pharmacist review) is exempt. - No. Uploads and downloads are presigned (10 min / 5 min), pharmacist views are proxied through the API, and
Uploadreturns no URL at all. Unverified: whether the R2 bucket itself still allows public read. That is infrastructure configuration, and team notes said files were still publicly fetchable at one point. - At order placement, inside the order transaction.
StockService.Reservelocks each variantFOR UPDATE, decrementsquantity, and records adispensemovement.Allocateat cart time changes nothing. hospital_payouts.fee_amount(rows not voided, bucketed byperiod_startin IST). Not order totals. The order count and average order value come fromorders.