Skip to content

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:

  1. Explain the three layers of a prescription (submission, page, review) and the statuses each one moves through.
  2. Trace an upload from presigned PUT to review queue, and say which sweeper recovers each step if it fails.
  3. Explain how the Gemini extraction pool is bounded, why it uses a claim token, and what happens to queued jobs at shutdown.
  4. Explain resolve-on-fetch: why a pharmacist's saved cart only reaches the patient's real cart when the patient opens the app.
  5. Read the inventory model (product → batch → product_variant → stock_movement) and explain FEFO allocation and reservation at order placement.
  6. Say what internal/hospitalcatalog and internal/dashboard compute, and which panel uses them.
  7. Explain how patient phone lookups work when the phone column is encrypted (blind indexes).

Reading order

#FileWhat to look for
1medyzen-backend/internal/prescription/module.goWiring: one service, one review service, four sweepers, one extraction service. The sweeper intervals (all 5 min).
2medyzen-backend/internal/prescription/models/submission.go, prescription.go, review.go, extraction.goThe status constants. MaxSubmissionPages = 10, SubmissionStaleWindow = 6h, LockTTL = 30s.
3medyzen-backend/internal/prescription/routes/routes.go, review_routes.goThe public surface: submissions, upload-url, confirm, patient-view, download-url, reviews, extract.
4medyzen-backend/internal/prescription/services/prescription.goCreateSubmission, CreateUpload, Confirm, Resubmit, toPatientView, DownloadURL.
5medyzen-backend/internal/pkg/storage/r2.goPresignPut, PresignGet, HeadObject, and the comment above Upload (lines 60–74) about public URLs.
6medyzen-backend/internal/prescription/services/review_statemachine.goThe whole review state machine in 27 lines.
7medyzen-backend/internal/prescription/services/review.goCreate, AutoCreateForPrescription, GetPageContent, Reject. Push now returns ErrReviewEndpointGone.
8medyzen-backend/internal/prescription/services/review_save_resolve.goClaim/Release (the edit lock), Save (Schedule H check), ResolveSavedCartsForPatient (resolve-on-fetch).
9medyzen-backend/internal/prescription/services/extraction.goRead the doc comments as carefully as the code. Pool size, queue capacity, stale threshold, reclaimStale, claim, writeReady.
10medyzen-backend/internal/pkg/genai/gemini.goThe prompt, the 8 MB image cap, temperature 0, JSON response.
11medyzen-backend/internal/prescription/services/stale_extraction_sweeper.go, submission_reaper.go, review_autoenqueue_sweeper.go, review_push_sweeper.goThe four sweepers.
12medyzen-backend/internal/pkg/worker/supervise.go + cmd/server/main.go lines ~255–280 and ~700–865How sweepers get restarted after a panic, and the shutdown order.
13medyzen-backend/internal/pkg/pii/pii.go + internal/database/migrations/253_patient_pii_ciphertext_columns.up.sqlEncryption and blind indexes.
14medyzen-backend/internal/inventory/models/*.goProduct, Batch, ProductVariant, StockMovement, StockCount.
15medyzen-backend/internal/inventory/services/stock.go + repositories/product_variant.go (ListAvailableByProduct)Allocate, Reserve, Release.
16medyzen-backend/internal/hospitalcatalog/services/medicines.go + repositories/medicines.goThe hospital panel's "medicines" page.
17medyzen-backend/internal/dashboard/services/dashboard.go, warehouse_summary.goThe two dashboard summaries.

Explanation

1. The shape of a prescription: three layers

A single "prescription" in the product is really three kinds of row:

LayerTableModelWhat it is
Submissionprescription_submissionsPrescriptionSubmissionOne upload event, 1–10 pages. It records page_count, stale_after and source (patient_app, whatsapp, kiosk).
PageprescriptionsPrescriptionOne image or PDF in R2. It has a storage_key, page_index and its own status. Page 0 is the anchor page.
Reviewprescription_reviews + prescription_review_itemsPrescriptionReviewA 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)HandlerWho calls it
POST /prescriptions/submissionsCreateSubmissionApp, kiosk
POST /prescriptions/submissions/resubmitResubmitApp
POST /prescriptions/upload-urlCreateUploadApp, kiosk
POST /prescriptions/{docId}/confirmConfirmApp, kiosk
GET /prescriptionsList (page-level)App
GET /prescriptions/patient-viewPatientView (submission-level, patient statuses)App
GET /prescriptions/{docId}/download-urlDownload (presigned GET, audited)App
POST /prescriptions/{docId}/extract, GET …/extractionExtractionKiosk operator only
POST /reviews, GET /reviews, GET /reviews/{id}Review create/list/detailWarehouse panel
POST /reviews/{id}/claim, /releaseEdit lockWarehouse panel
POST /reviews/{id}/save, /rejectDecideWarehouse panel
POST /reviews/{id}/pushGone (ErrReviewEndpointGone)Nobody
GET /reviews/{id}/pages/{docId}/contentProxied page bytesWarehouse 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/png and application/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 returns ErrPrescriptionNotUploaded. If it is over maxPrescriptionPageBytes (25 MB), confirm deletes the object and returns ErrPrescriptionPageTooLarge (prescription.go:421–443).
  • Completion is a conditional UPDATE, not a read-then-write. TryComplete (repositories/submission.go:59) flips collecting → complete only when page_count equals the count of uploaded pages, 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, assertOrderBelongsToPatient checks 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-new complete submission. The new page rows reuse the old storage_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_pushed still exists as a constant, but it has no edges. It is legacy from the old "pharmacist pushes cart" flow. review_statemachine_matrix_test.go has TestCanTransitionReview_CartPushedIsFrozenLegacyAndHasNoEdges. The Push endpoint now returns ErrReviewEndpointGone (review.go:426).
  • patient_responded and expired are 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. collecting becomes pending_upload. abandoned becomes incomplete. pending_review and cart_saved both become queued. cart_resolved becomes cart_pushed. cancelled becomes rejected.

4. The review: queue, lock, save

Getting into the queue. A review is created in one of three ways:

  1. Confirm wins TryComplete and calls AutoCreateForPrescription(anchor) inline.
  2. Resubmit does the same for its new submission.
  3. 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):

ConstantValueWhy
extractionPoolSize4At most 4 Gemini calls and 4 DB connections at once (out of DB_MAX_OPEN_CONNS=20).
extractionQueueCapacity64Buffered channel. When it is full, the job is dropped and the row is marked failed immediately, so the operator can retry.
extractionRunTimeout60 sOne job: R2 read + Gemini call + write-back.
extractionStaleAfter20 minMust 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 / writeFailed cannot overwrite a newer attempt's result.
  • reclaimStale and the sweeper compute the cutoff with now() 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)TypeWhat it findsWhat it doesWhy it exists
prescription.review_sweeperPrescriptionReviewPushSweeper (review_push_sweeper.go)prescription_review_pushes with delivered_at IS NULL, next_attempt_at <= now, attempts < 5Claims 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 exhaustedThe inline send after Save/Resolve can fail; the outbox row guarantees a retry.
prescription.auto_enqueue_sweeperPrescriptionReviewAutoEnqueueSweeperAnchor pages (page_index = 0, uploaded) of complete submissions with no non-deleted review (ListUnroutedUploaded)AutoCreateForPrescription for up to 50Confirm commits first and enqueues after. A failure in between would otherwise strand the prescription forever.
prescription.submission_reaperPrescriptionSubmissionReaper (submission_reaper.go)Submissions still collecting after stale_after (6 h)MarkAbandoned (conditional on still collecting), then deletes each page's object from R2Half-uploaded prescriptions are health data nobody will use. Rows are kept; bytes are purged.
prescription.stale_extraction_sweeperStaleExtractionSweeperExtractions processing for longer than 20 minOne UPDATE → failed, batch of 200, FOR UPDATE SKIP LOCKEDreclaimStale 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 a recover(). 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 ctx is 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 ctx is cancelled.

main.go uses two contexts, and the difference matters:

  • reconcilerCtx is cancelled by stopReconciler() the moment a signal arrives. The four sweepers run under it, so they stop taking new ticks right away.
  • poolCtx is cancelled by stopPools() only after server.Shutdown and adminServer.Shutdown return. The extraction pool runs under it. A request that is still draining can call Start and 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.

CheckWhereWhat it does
Product flagsinventory/models/product.gorequires_prescription bool and schedule (none, g, h, h1, x, narcotic). Two separate fields.
Patient OTC search hides Rxinventory/repositories/search.go productSearchOTCSQLAND p.requires_prescription = false. Route GET /inventory/api/v1/products/search/otc is app-only.
Patients cannot add or increase Rx linescart/services/cart.go:304 enforceQuantityCapCalled 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 itemscart/services/cart.go:396 PushReviewItemsProducts 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/H1prescription/services/review_save_resolve.go:217–231 + services/prescriber.go scheduleHRequiresPrescriberIf 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 logsreview_save_resolve.go:419–435A saved review with an H line and no prescriber snapshot produces a warning, not a block.
Schedule H registerGET /inventory/api/v1/reports/schedule-hReport 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 PresignGet with a 5-minute TTL. Order pages for staff use PagesForOrder with a 5-minute TTL. Pharmacist review pages are streamed by GetPageContent. Uploads use PresignPut with a 10-minute TTL.
  • R2Client.Upload deliberately 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:

  1. Migration 253 adds the columns.
  2. The app deploy writes both.
  3. server -encrypt-patient-pii -commit backfills old rows (cmd/server/main.go:92–94; without -commit it is a dry run; pkg/pii/backfill.go).
  4. ops/pending-migrations/254_patient_drop_plaintext_pii drops the plaintext. It lives in ops/, 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.go phoneLookup computes PhoneIndex(e164) and queries <column>_bidx = ?. No cipher, or a value it cannot index, becomes WHERE 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), or patient_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 as drr. NamePrefixLen cannot 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

  • products is the global catalogue: name, composition, form, strength, schedule, requires_prescription, GST/HSN and cold_chain. Reads go through a 30-second in-process cache (repositories/product_cache.go). The cache is bypassed inside a transaction (database.HasTx).
  • batches are receipts at one warehouse (purchase, return, transfer), optionally linked to a purchase order (Day 5).
  • product_variants holds the stock. Each row is one product, in one batch, at one warehouse, with one expiry_date, quantity, MRP/purchase/selling price, rack and a status of in_stock, out_of_stock or expired. Stock is per warehouse and per batch.
  • stock_movements is the ledger: intake, adjustment, dispense, return, transfer_in/out, expiry_writeoff, return_writeoff, return_to_vendor and count_adjustment, each with quantity_delta and quantity_after.
  • stock_counts is cycle counting. stock_count_items.variance is a generated column (<-:false). Applying a count calls StockService.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, completed and partially_returned orders);
  • 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), bucketed not_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:

EndpointPanelScopeComputes
GET /dashboard/api/v1/summaryHospitalHospitalScopeTotal 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-summaryWarehouse (super_admin may pass warehouse_id)WarehouseAccess.ScopeToday'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

  1. 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.

  2. Raw SQL skips GORM hooks, so it skips encryption. BeforeSave seals lines. A db.Exec("UPDATE prescription_extractions SET lines = …") does not. writeReady seals by hand for exactly this reason. The same applies to orders, users and patient_addresses: any raw write of a PII field must also write _ciphertext and every matching _bidx column (see sealedPhoneColumns in human/repositories/user.go). If you forget the blind index, the patient can no longer log in.

  3. Blind indexes match exactly, after normalization. You cannot LIKE a blind index. Phone search works only on 10+ digits or exactly 4 digits (order.go:238–249). Five digits match nothing except order_no. A new lookup must use the same Normalize* function and the same prefix, or it silently finds nobody.

  4. Keys are mandatory, including locally. Without PATIENT_ENCRYPTION_KEY and PATIENT_BLIND_INDEX_KEY, the server exits at boot. docker-compose.yml does not set them, so make docker-up will not start the API as-is. Use throwaway local keys. Never copy real ones.

  5. prescription_reviews.patient_name is 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.

  6. Sweeper claims must use SKIP LOCKED or a conditional UPDATE. Every task runs every sweeper. The push sweeper and stale-extraction sweeper use FOR UPDATE SKIP LOCKED. The reaper relies on MarkAbandoned … WHERE status = 'collecting'. The auto-enqueue sweeper relies on a unique index plus ExistsOpenForSubmission. If you write a new sweeper with plain select-then-update, two tasks will process the same row.

  7. The push sweeper can double-notify. If the transport succeeds but MarkDelivered fails, the next tick sends again. The comment at review_push_sweeper.go:100 relies on the transport's idempotency key. When you add a transport, it must honour IdempotencyKey.

  8. 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 on context.WithoutCancel. Wait on Done() 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 the workers wait set in main.go.

  9. Changing pool numbers can break the stale threshold. If you raise extractionQueueCapacity or lower extractionPoolSize without raising extractionStaleAfter, 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 file extraction_threshold_invariant_test.go, but the file on disk is extraction_threshold_test.go.

  10. 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.

  11. Use the database clock for anything compared in SQL. reclaimStale, claim and the sweeper all use now() in SQL, never time.Now(). Claim uses clock.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.

  12. Resolve-on-fetch runs on a GET. GET cart writes 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 the app channel.

  13. Schedule H needs both flags set correctly. Hiding a product from patients depends on requires_prescription. The prescriber requirement depends on schedule IN ('h','h1'). A product with schedule = 'h1' but requires_prescription = false appears in OTC search and can be added freely. Catalogue data quality is part of compliance.

  14. Stock availability queries do not all agree. ListAvailableByProduct excludes expired batches (expiry_date > now). hospitalcatalog AvailableStock does not filter on expiry. ProductVariantService.Adjust sets status = in_stock for any non-zero quantity, even on a variant marked expired. 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).

bash
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 up
  1. State machine by test. Run go test ./internal/prescription/services -run 'TestCanTransitionReview|TestAllowedReviewTransitions' -v. Then, on a local branch, temporarily add cart_saved → pending_review to allowedReviewTransitions and run the tests again. Which test fails, and why is that edge dangerous? Revert.

  2. Supervisor behaviour. Run go test ./internal/pkg/worker -v. Read TestSupervise_RestartsAfterPanicAndReturnsCleanlyOnCancel. Explain in two sentences why a sweeper that returns nil early while ctx is live gets restarted, but one that returns after cancellation does not.

  3. Claim token under a real database. Run the extraction integration tests against the scratch database:

    bash
    TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5434/medyzen_test?sslmode=disable" \
      go test -tags integration ./internal/prescription/services -run 'Reclaim|StaleExtraction' -v

    Find the test named in the reclaimStale doc comment (TestReclaimStale_ClockSkewedWinnerCannotBeReReclaimed) and describe the race it prevents.

  4. Blind index by hand. In a scratch test file (do not commit), build a cipher with pii.New and your two local keys. Print PhoneIndex("+91 98765 43210"), PhoneIndex("9876543210") and PhoneLast4Index("3210"). Confirm the first two are equal. Explain why PhoneIndex("98765") returns "".

  5. Watch the sweeper SQL. Start the server locally with GEMINI_API_KEY empty. In psql on port 5434, insert a prescription_submissions row with status='collecting' and stale_after = now() - interval '1 hour'. Wait for the next reaper tick (up to 5 min) and confirm it becomes abandoned. R2 is unconfigured locally, so purgePages returns early. Point out the line where that happens.

  6. FEFO. In local psql, create one product and two product_variants in the same warehouse with different expiry_dates. Call StockService.Allocate from a scratch test (or read ListAvailableByProduct) and predict which variant is taken first. Add a third variant that has already expired, and confirm it is ignored.

  7. Shutdown ordering. Read main.go from the signal to sqlDB.Close(). Write the ordered list of steps in your notes. Then answer: if stopPools() moved to just after stopReconciler(), what exact failure would a kiosk operator see during a deploy?


Self-check

  1. What are the three layers of a prescription, and which one does a pharmacist review?
  2. What makes TryComplete safe when two pages confirm at the same moment?
  3. The pharmacist saves a review. When does the patient's cart actually change, and why is it designed that way?
  4. Name the four prescription sweepers and, for each, the failure it recovers from.
  5. What is the extraction "claim token", where does its value come from, and what does it prevent?
  6. Why is extractionStaleAfter 20 minutes rather than 5?
  7. On shutdown, why is poolCtx cancelled after server.Shutdown, but reconcilerCtx cancelled immediately?
  8. How does the login code find a user by phone number when the phone column is encrypted?
  9. Where in the code is a patient stopped from adding a prescription-only medicine to their own cart?
  10. Does the code ever hand out a public R2 URL for a prescription? What is still unverified?
  11. At which moment does stock leave product_variants.quantity for an order, and which movement type is recorded?
  12. The hospital dashboard's "Total revenue" — which table does it sum?

Answers

  1. 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.
  2. It is a single conditional UPDATE … WHERE status = 'collecting' AND page_count = (SELECT count(*) … status = 'uploaded'), and it returns RowsAffected == 1. Only one caller can flip the row, so only that caller auto-enqueues the review.
  3. Only when the patient fetches their app cart. CartService.GetActive calls ResolveSavedCartsForPatient, which moves cart_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.
  4. 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 stuck collecting after 6 h (marks them abandoned, deletes R2 bytes). stale_extraction_sweeper: extractions stuck processing for over 20 min, for example after a deploy killed the task.
  5. It is the prescription_extractions.updated_at value that Postgres returned (RETURNING) when the row was set to processing. claim, writeReady and writeFailed all require updated_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.
  6. 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.
  7. 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. poolWorker also drains buffered jobs before honouring cancellation.
  8. It normalizes the phone to its last 10 digits and computes HMAC(blind-index key, "phone:" + digits). Then it queries phone_e164_bidx = ? (human/repositories/user.go phoneLookup). There is no plaintext fallback. With no cipher, the query becomes WHERE FALSE.
  9. Two places. SearchInStockOTC never shows products with requires_prescription = true. enforceQuantityCap (cart/services/cart.go:304) rejects any increase, including from zero, of an Rx line. Only PushReviewItems (lines from a pharmacist review) is exempt.
  10. No. Uploads and downloads are presigned (10 min / 5 min), pharmacist views are proxied through the API, and Upload returns 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.
  11. At order placement, inside the order transaction. StockService.Reserve locks each variant FOR UPDATE, decrements quantity, and records a dispense movement. Allocate at cart time changes nothing.
  12. hospital_payouts.fee_amount (rows not voided, bucketed by period_start in IST). Not order totals. The order count and average order value come from orders.

Internal — written against the code, not the other way around.