bool-api is a scalable Smart Price Comparison Platform, a "Waze for Prices".
- Go
- Gin
- Bun ORM
- PostgreSQL
- Optional Redis support
- Viper
- Zap
- go-playground/validator
cmd/api: application entrypointconfig: application configurationinternal/bootstrap: infrastructure initializationinternal/models: database modelsinternal/dto: request and response DTOsinternal/repositories: data access implementationsinternal/repositories/interfaces: repository contractsinternal/services: business logicinternal/handlers: HTTP handlersinternal/routes: route registrationinternal/middleware: HTTP middlewareinternal/normalization: product matching and normalizationinternal/confidence: confidence scoring rulesinternal/whatsapp: WhatsApp bot integrationinternal/supplier: supplier imports and dashboard supportinternal/jobs: background jobsmigrations: SQL migrationsscripts: operational scriptstests: integration and behavior testsdocs: architecture and API documentation
go mod tidy
go run ./cmd/apiHealth check:
curl http://localhost:8080/healthBOOL uses versioned SQL migrations in migrations/*.up.sql, applied through Bun's migration runner. The API can run pending migrations before the Gin server starts:
AUTO_RUN_MIGRATIONS=true go run ./cmd/apiWhen AUTO_RUN_MIGRATIONS=true, startup initializes the Bun migration tables if needed, applies all pending migrations, logs each migration, and only then starts the HTTP server. If any migration fails, the API fails fast and does not start. When AUTO_RUN_MIGRATIONS=false, automatic startup migrations are skipped.
You can also run migrations explicitly:
go run ./cmd/api migrateDeploy examples:
# Railway / VPS
APP_ENV=production
DATABASE_URL=postgres://...
AUTO_RUN_MIGRATIONS=trueRollback code is manual for now. Do not run destructive rollback automatically during deploys; take a database snapshot before risky changes and roll forward when practical.
Many platform-level operations (creating or editing global products, global vendors, global prices, etc.) require a user with platform_role = 'super_admin'.
For local development, after starting the server and creating an account through the normal auth flow:
go run ./cmd/seed-super-admin --email admin@example.comThis promotes the existing user to super_admin (and sets account_status = 'active'). The command refuses to run when APP_ENV=production.
In development the auth verify step returns a data.dev_code you can use directly for testing. See the Postman collection in docs/ for convenient examples using admin@example.com.
Production and staging must set explicit, safe environment values:
APP_ENV=productionorAPP_ENV=stagingHTTP_ADDRDATABASE_URLJWT_SECRETwith at least 32 characters and notchange-meSENDGRID_API_KEYfrom the SendGrid dashboardAUTH_EMAIL_FROM, for exampleonboarding@bool.localAUTO_RUN_MIGRATIONS=truewhen the deployment should apply pending migrations before startupCORS_ALLOWED_ORIGINSwith explicit origins only;*is rejectedAUTH_COOKIE_SAMESITE=noneandAUTH_COOKIE_SECURE=truewhen the dashboard and API are cross-originAUTH_COOKIE_DOMAIN, optionally, for a shared parent domain such as.yourdomain.com
The API fails fast at startup when these values are unsafe. Development keeps local defaults for convenience and logs warnings if insecure defaults are active.
Authentication emails are sent through SendGrid when SENDGRID_API_KEY is configured. Copy .env.example and replace the placeholder SG.xxxxxxxxx with a real SendGrid API key from the SendGrid dashboard:
SENDGRID_API_KEY=SG.xxxxxxxxxx
AUTH_EMAIL_FROM=onboarding@bool.localDo not commit real SendGrid API keys. In development, missing SendGrid configuration logs a warning and uses the mock auth email sender while still returning data.dev_code for local testing. In production and staging, missing SendGrid configuration fails startup validation.
Auth uses short-lived HMAC-signed JWT access tokens. OTP login codes are stored in PostgreSQL by default in auth_login_challenges; Redis is not required for request-login or verify. POST /api/v1/auth/verify returns a 15 minute access token and sets the refresh token in the HTTP-only bool_refresh cookie. POST /api/v1/auth/refresh reads that cookie, validates and rotates the refresh token when Redis-backed refresh sessions are available, and sets a replacement cookie. POST /api/v1/auth/logout clears the cookie, deletes the refresh session when present, and all_devices=true increments users.session_version so existing access tokens become invalid. Cross-origin dashboard/API deployments must use an exact CORS_ALLOWED_ORIGINS dashboard origin with credentials, plus AUTH_COOKIE_SAMESITE=none and AUTH_COOKIE_SECURE=true so browsers send the refresh cookie on frontend refresh calls.
Request identity is derived from verified token/session state only. Active organization context and organization role are loaded from active database membership. Platform role is loaded from the verified user record. The old development headers X-User-ID, X-Organization-ID, X-Organization-Role, and X-Platform-Role are forbidden in production and staging. They are accepted only when both APP_ENV=development and ALLOW_DEV_AUTH_HEADERS=true are set.
Dev auth headers are forbidden in production.
Redis is disabled by default with REDIS_ENABLED=false. When disabled, the API does not create a Redis client; rate limits use the in-memory fallback and auth OTPs use PostgreSQL. When enabled, the API prefers REDIS_URL and supports both redis:// and rediss:// URLs. REDIS_ADDR=localhost:6379 is only a local development fallback; production and staging require REDIS_URL and must not rely on localhost Redis. Startup pings Redis when REDIS_ENABLED=true and fails clearly if Redis cannot be reached.
Railway Redis setup:
- Add a Redis service in Railway.
- In
bool-apivariables set:
REDIS_ENABLED=true
REDIS_URL=${{Redis.REDIS_URL}}- Remove or ignore
REDIS_ADDR=localhost:6379in production. - Redeploy
bool-api.
Rate limits use Redis only when REDIS_ENABLED=true and Redis passes the startup ping, with an in-memory fallback when Redis is disabled. Keys use authenticated user ID when available and client IP otherwise. Current route limits:
- Auth: request login
5/min, verify10/min, logout/refresh10/min - Invites: accept and create
20/min - Search/read-heavy routes:
60/min - Submit/write-heavy routes:
20/min - Admin mutations:
30/min
Pagination is bounded globally for list/search handlers. Search defaults to 25, list/admin defaults to 50, maximum limit is 100, and invalid negative/non-numeric/extreme values are rejected. GET /health is an alive probe; GET /ready checks database, Redis when configured, and critical config for load balancers.
Operational backup, restore, secret rotation, and incident response guidance lives in docs/operations.md.
Manual QA checklist:
- Start with
APP_ENV=production JWT_SECRET=change-me; startup must fail. - Call a protected route with only fake
X-User-IDorX-Platform-Role; it must return401or403. - Log in, call protected routes with the returned access token, and verify the active organization comes from membership.
- Spam
/api/v1/auth/request-loginor a protected search route until it returns429. - Confirm browser storage and JSON API responses do not contain a refresh token after login.
The Product Foundation layer defines BOOL's canonical product dictionary before any price workflows are added. Product != Price: products describe what an item is; prices, baskets, vendors, OCR, AI matching, WhatsApp flows, and supplier imports are separate layers.
Global product dictionary = platform controlled. Organization product dictionary = workspace controlled. User-submitted products = pending until reviewed.
BOOL has two product visibility paths in the MVP:
- Global products have
products.organization_id IS NULL. They are official BOOL platform products, visible to all organizations, and can only be created or changed by users withplatform_role = 'admin'orplatform_role = 'super_admin'. - Organization products have
products.organization_id = active_organization_id. They are private to that organization and can be created or changed by organizationowner,admin,manager, orsupplier_managerusers.
Normal users cannot create active products directly. They submit candidates to product_pending_queue, where managers review them and either create a product, merge with an accessible product, or reject the submission. All user-submitted candidates are scoped to the active organization. Platform admins may later promote a candidate into the global product dictionary.
Product search requires auth and active organization context. It returns active, non-deleted, non-merged global products and active organization products only. Supplier workspace products remain private to the supplier workspace unless a later platform-admin workflow copies or promotes them into the global dictionary or a buyer workspace. Search checks barcode, canonical name, normalized name, aliases, and normalized aliases. Exact barcode matches score 1.0; low-confidence matches below 0.65 stay pending instead of auto-matching.
Dashboard product management uses GET /api/v1/admin/products, which supports q, scope=global|organization|all, status=active|inactive|merged|deleted, brand, category_id, unit_type, page, and limit. Organization managers see global products plus their active organization products. Platform admins can list all scopes.
Example requests:
curl -H "Authorization: Bearer dev" \
"http://localhost:8080/api/v1/products/search?q=milk&limit=10"
curl -X POST http://localhost:8080/api/v1/products/submit-candidate \
-H "Authorization: Bearer dev" \
-H "Content-Type: application/json" \
-d '{"submitted_name":"חלב 3%","raw_input":"חלב 3%"}'
curl -X POST http://localhost:8080/api/v1/admin/products \
-H "Authorization: Bearer dev" \
-H "Content-Type: application/json" \
-d '{"organization_id":"{{organization_id}}","canonical_name":"Milk 3%","brand":"Tnuva","unit_type":"volume","unit_size":1,"unit_standard":"l"}'
curl -H "Authorization: Bearer dev" \
"http://localhost:8080/api/v1/admin/products?scope=all&status=active&limit=50"The trusted price layer follows BOOL's core model:
Product != Vendor != Price.
Product = item identity.
Vendor = seller/supplier identity.
Price = a time-based record connecting product_id, vendor_id, optional vendor_branch_id, optional organization context, and captured_at.
Users create demand signals. Suppliers create prices. Admins protect trust. Organizations consume intelligence.
Trusted prices live in prices. Global prices have prices.organization_id IS NULL and can be created, updated, verified, or deleted only by platform admin / super_admin users. Organization prices have prices.organization_id = active_organization_id and can be managed by organization owner, admin, manager, or permitted supplier_manager users. Service code never trusts organization_id from the request body without comparing it to the active organization context.
Regular users do not create trusted prices directly. They can:
- report a wrong price or say they found it cheaper through
price_reports - request a price check or quote through
price_requests - create demand signals for suppliers, organization managers, and admins
Price reports do not automatically update trusted prices. Price requests are demand signals only.
Every price, report, and request stores its own currency. Empty currency defaults to ILS; supported currencies are ILS, USD, and EUR. The MVP does not convert currencies, fetch exchange rates, or compare mixed currencies.
Supplier prices use source_type = 'supplier'; trusted prices never use source_type = 'user'. Supplier-created prices are private to the active supplier workspace by default, start unverified with default confidence 0.75, and can be confirmed by the supplier. Buyer workspaces only see supplier-origin prices if a later explicit platform-admin workflow copies or promotes them into a global price (organization_id IS NULL) or buyer organization price (organization_id = buyer_org_id). Platform admins can verify prices and set confidence to 1.00. Admin-created prices default to confidence 0.80; API/import/system defaults are 0.90, 0.70, and 0.60.
Search returns only global prices (organization_id IS NULL) and the active organization's private prices (organization_id = active_organization_id). It excludes another organization's private prices, including supplier-workspace price rows, and excludes deleted prices and expired prices unless include_expired=true.
Price responses include a deterministic confidence score from 0 to 1 and a stable confidence_label.
0-0.54:low0.55-0.79:medium0.80-1:high
price_history is append-only. Historical rows are inserted automatically when a trusted price is created, updated, verified, confirmed, promoted, or when a submitted price is approved into an existing price. Existing active prices are copied once by migration 027_price_history_analytics.up.sql as the baseline; the migration intentionally does not reconstruct older events that were not previously stored.
Duplicate protection is enforced by a unique index across product, vendor, branch, price, currency, and approval timestamp. Analytics indexes cover product/date history, organization-visible product history, vendor history, branch history, and approved price lookup.
Product analytics endpoints:
GET /api/v1/products/:id/price-historyGET /api/v1/products/:id/intelligence?period_days=30GET /api/v1/products/:id/analytics?period_days=30
Supported trend periods are 7, 30, 90, and 180 days; other values fall back to 30. Product access, organization visibility, active vendor/product filters, suspended price rules, and pagination limits follow the existing price service patterns. Confidence scoring remains deterministic: freshness 50%, approval status 20%, branch-specific pricing 15%, and multiple confirmations 15%.
The score is a display-time quality signal, not an AI prediction. It starts from the price source and verification state, then adjusts for recency, branch specificity, unit data, and availability. Verified admin and verified supplier prices receive the strongest trust boost. Prices captured within 7 days receive the largest recency boost, prices within 30 days receive a smaller boost, and older prices are penalized. Branch-specific prices and prices with usable unit data score higher. Missing branch context, unverified sources, stale records, and out-of-stock records reduce confidence. Scores are capped at 1 and never go below 0.
PATCH price updates leave vendor_branch_id unchanged when omitted. Sending vendor_branch_id assigns a validated branch for the same vendor/workspace. Sending clear_vendor_branch: true explicitly clears the branch association. Requests that include both vendor_branch_id and clear_vendor_branch: true are rejected with the standard validation error response.
Global submission review uses platform role from the verified user/session only.
Price routes:
GET /api/v1/prices/searchGET /api/v1/products/:id/pricesPOST /api/v1/prices/:id/reportPOST /api/v1/products/:id/report-pricePOST /api/v1/products/:id/request-price-checkPOST /api/v1/products/:id/request-quoteGET /api/v1/admin/pricesPOST /api/v1/admin/pricesPATCH /api/v1/admin/prices/:idDELETE /api/v1/admin/prices/:idPOST /api/v1/admin/prices/:id/verifyPOST /api/v1/admin/prices/:id/confirmGET /api/v1/admin/price-reportsPOST /api/v1/admin/price-reports/:id/resolveGET /api/v1/admin/price-requestsPOST /api/v1/admin/price-requests/:id/resolveGET /api/v1/supplier/pricesPOST /api/v1/supplier/pricesPATCH /api/v1/supplier/prices/:idPOST /api/v1/supplier/prices/:id/confirmGET /api/v1/supplier/intelligence/summaryGET /api/v1/supplier/price-requestsPOST /api/v1/supplier/price-requests/:id/respond
Supplier Pricing Intelligence gives supplier workspaces lightweight operational visibility into where their active prices are competitive, where they are losing to visible market prices, and where recent demand lacks supplier coverage. It is not forecasting, procurement automation, bidding, or a BI dashboard.
Competitiveness rules:
- Visibility always follows the normal price rule:
prices.organization_id IS NULL OR prices.organization_id = active_organization_id. - Deleted, suspended, expired, inactive-branch, inactive-vendor, and out-of-stock prices are excluded.
- Supplier products are priced when the active supplier workspace owns the vendor on an active visible price.
- A product is cheapest when the supplier's best active visible price is equal to the lowest visible market price. Equal lowest prices count as competitive.
- Tie ranking uses lowest price, verified prices first, higher confidence first, then newest
captured_at. - A product is losing when another visible vendor has a lower active visible market price. Results are sorted by biggest percentage gap.
- Coverage is
priced_products / (priced_products + missing_products) * 100, rounded to one decimal. When the denominator is zero, coverage is0.0.
Missing coverage is intentionally scoped to products with recent operational signal:
- products used in baskets in the last 30 days, or
- products with visible pricing activity in the last 90 days.
Brand new products with no pricing or basket activity may not appear in missing coverage yet.
Sample response:
{
"supplier_id": "00000000-0000-0000-0000-000000000000",
"currency": "ILS",
"generated_at": "2026-05-19T12:00:00Z",
"summary": {
"total_products_priced": 340,
"cheapest_products": 112,
"losing_products": 148,
"missing_prices": 80,
"coverage_percent": 76.4
},
"top_cheapest_products": [],
"top_losing_products": [],
"missing_products": []
}Calculation sketch:
visible_prices = active prices visible to the supplier workspace
supplier_best = best visible supplier-owned price per product and currency
market_best = best visible market price per product and currency
competitor_best = best visible non-supplier price per product and currency
cheapest = supplier_best where supplier_price = market_best.price
losing = supplier_best where competitor_best.price < supplier_price
missing = recent active products with market_best but no supplier_best
coverage = priced / (priced + missing) * 100Future ideas: historical trends, notifications, category insights, and predictive pricing.
The procurement layer keeps the operational concepts separate:
Basket != Procurement Request != Supplier Quote.
Organizations can convert a priced basket into a draft procurement request, submit it for approval, and have an organization manager/admin/owner send it to a supplier. Supplier workspaces only see requests where supplier_organization_id equals their active organization and can save/submit a supplier quote.
stateDiagram-v2
[*] --> draft
draft --> pending_approval
pending_approval --> approved
pending_approval --> rejected
approved --> sent_to_supplier
approved --> cancelled
sent_to_supplier --> supplier_responded
sent_to_supplier --> cancelled
supplier_responded --> quote_accepted
supplier_responded --> quote_rejected
Snapshot architecture:
procurement_requestsowns the buyer workflow header and selected supplier/vendor context.procurement_request_itemsowns immutable commercial item snapshots. Detail pages render product name, supplier/branch display names, selected price, currency, confidence score, source type, andcaptured_atfrom these rows instead of live price/vendor/product lookups.supplier_quotesowns supplier response headers.supplier_quote_itemsowns quote line items and referencesprocurement_request_items, leaving room for RFQs, multi-supplier bidding, revisions, comparison, and negotiation.- Quote item writes are request-scoped: every submitted
procurement_item_idmust belong to the same procurement request. Quote-only add-ons such as delivery fees, packaging fees, or substitutions are not supported by the current schema becausesupplier_quote_items.procurement_item_idis required. Draft quote saves use replace semantics for line items, so omitted quote items are removed from that draft.
Commercial immutability:
- Before send, draft procurement metadata and item snapshots can be edited through backend service rules.
- After
sent_to_supplier, procurement supplier, items, quantities, vendor/branch selection, selected prices, and regenerated snapshots are forbidden. - After send, supplier quote creation/updates, cancellation, internal notes, audit events, and future notification hooks remain allowed.
- Live price changes, branch/vendor deletion, and product merges must not alter procurement history because the commercial record is already captured in snapshot columns.
Valid request transitions:
draft -> pending_approvalpending_approval -> approvedpending_approval -> rejectedapproved -> sent_to_suppliersent_to_supplier -> supplier_respondedsupplier_responded -> quote_acceptedsupplier_responded -> quote_rejectedapproved -> cancelledsent_to_supplier -> cancelled
Invalid transitions return:
{
"error": {
"code": "INVALID_STATE_TRANSITION"
}
}Organization routes:
GET /api/v1/procurement/summaryGET /api/v1/procurementPOST /api/v1/procurementGET /api/v1/procurement/:idPATCH /api/v1/procurement/:idPOST /api/v1/procurement/:id/submitPOST /api/v1/procurement/:id/approvePOST /api/v1/procurement/:id/rejectPOST /api/v1/procurement/:id/acceptGET /api/v1/procurement/:id/timelinePOST /api/v1/procurement/:id/sendPOST /api/v1/procurement/:id/cancelPOST /api/v1/baskets/:id/create-procurement
Supplier routes:
GET /api/v1/supplier/procurement-summaryGET /api/v1/supplier/procurementGET /api/v1/supplier/procurement/:idPOST /api/v1/supplier/procurement/:id/quotePATCH /api/v1/supplier/procurement/:id/quotePOST /api/v1/supplier/procurement/:id/quote/submit
Permission matrix:
- Personal/normal users cannot manage procurement.
- Organization owner/admin/manager/organization_manager can submit, approve, reject, send, and cancel according to state rules.
- Supplier owner/admin/manager/supplier_manager can only view/respond to requests sent to their active supplier organization.
- Buyer procurement summaries are scoped to the active non-supplier organization and return counts only.
- Supplier procurement summaries are scoped to the active supplier/wholesaler organization and return counts only.
- Platform admins still go through service-level procurement ownership and workflow constraints.
GET /api/v1/procurement/summary returns buyer operational counts for the active buyer organization:
{
"draft": 4,
"pending_approval": 2,
"approved": 5,
"supplier_responded": 3,
"quote_accepted": 12,
"quote_rejected": 1,
"cancelled": 0
}GET /api/v1/supplier/procurement-summary returns supplier operational counts for the active supplier organization:
{
"new_requests": 5,
"awaiting_response": 3,
"submitted_quotes": 12,
"completed": 8
}Both endpoints use aggregate queries and do not return procurement payloads. Buyer summaries reject active supplier organizations; supplier summaries reject active buyer organizations.
Run the pilot-ready demo seed with:
go run cmd/seed-demo/main.goThe seed creates demo buyer and supplier organizations, supplier-linked vendors and branches, catalog prices, procurement requests across draft, pending approval, sent to supplier, supplier responded, accepted, and rejected states, plus quote drafts/submissions and audit activity. It is idempotent for core demo records and supports a complete buyer to supplier to buyer workflow without manual setup.
After a supplier submits a quote, the procurement request enters supplier_responded. Buyer organization managers can then call POST /api/v1/procurement/:id/accept to move the request to quote_accepted, or POST /api/v1/procurement/:id/reject with an optional reason to move it to quote_rejected. Supplier users cannot perform either decision action.
Procurement detail responses include read-only quote_comparison metadata when a submitted quote exists. The summary exposes original_total, supplier_total, difference, and difference_percent; each item exposes original price, quoted price, delta, delta percent, quantity change flag, and row status. Comparison rows are anchored to procurement request items; additional quote-only rows cannot exist until the quote item schema supports nullable or typed non-request line items.
This sprint extends the existing procurement system with a lightweight supplier response mechanism (separate from but compatible with detailed supplier_quotes).
Buyer side (procurement_requests.status):
draft→pending_approval→approved→sent_to_suppliersent_to_supplier→partially_responded(auto on first supplier response) orsupplier_responded(via quote)partially_responded/supplier_responded→completed(buyer selects supplier) orcancelled- Terminals:
completed,cancelled,fulfilled,rejected,quote_rejected
Supplier side (per procurement_response):
pending(default on create)accepted— supplier can fulfillrejected— cannot fulfillcounter_offered— can fulfill with changes (quantity/branch/price/alternative noted innotes)
- Buyer creates/sends procurement request to a supplier organization (existing).
- Supplier (via supplier workspace) calls
POST /api/v1/supplier/procurement/:id/respondwith{ "status": "accepted", "notes": "..." }(orrejected/counter_offered). - System creates
procurement_responsesrow (unique per request+supplier), writes audit, auto-advances PR topartially_respondedvia central status engine if first response. - Supplier can
PATCH /api/v1/supplier/procurement/responses/:responseIdwhile request open. - Buyer views
GET /api/v1/procurement/:id/responses(includes summary counts for UI panel: total, received, accepted, rejected, counter). - Buyer calls
POST /api/v1/procurement/:id/select-supplier{ "supplier_id": "..." }→ setscompleted+selected_supplier_id, timeline event, audit. - All actions emit
audit_logs(targetprocurement_request) → appear in existing/timeline.
Draft → Pending Approval → Approved → Sent
↓
Supplier responds (accept/reject/counter)
↓
Partially Responded (or Supplier Responded via quote)
↓
Buyer: Select Supplier → Completed | Cancel → Cancelled
- Supplier (supplier_manager/owner on the supplier org matching PR.supplier_organization_id): create/update own response while request !terminal and CanSupplierRespond(status). View responses for their requests.
- Buyer org (owner/admin/manager/organization_manager on PR.organization_id): view all responses, select supplier (→ completed), cancel. Cannot create or edit supplier responses (impersonation blocked).
- Wrong org → 403/404 (permission or not found, consistent with existing).
POST /api/v1/supplier/procurement/:id/respond— supplier creates response (payload: status, notes, optional branch)PATCH /api/v1/supplier/procurement/responses/:responseId— supplier updates own responseGET /api/v1/procurement/:id/responses— buyer (or associated supplier) gets list + summary countsPOST /api/v1/procurement/:id/select-supplier— buyer marks chosen supplier (sets completed + selected_supplier_id)
All reuse existing auth (SupplierOnlyMiddleware for supplier paths, org scoping, role checks in service).
Every response create/update, auto status change ("status_auto_updated"), supplier selection, and cancel write audit_logs. Timeline query includes the new action types and maps them to friendly labels ("supplier accepted", "supplier selected", etc.).
- Unit tests cover: response create (all 3 statuses), auto status transition to
partially_responded, buyer select supplier (sets completed + selected_supplier_id), wrong-supplier authz rejection. - Existing procurement state machine, quote, and timeline tests continue to pass (no breakage).
- Run
go test ./internal/services -run 'TestSupplierCanRespond|TestBuyerCanSelect|TestWrongSupplier|TestProcurement'. - Manual: seed demo, send PR, supplier respond via Postman/curl under supplier org, buyer views responses + selects, check timeline and audit_logs.
The response workflow is additive: existing quote flow, approvals, delivery steps, and UI pages remain unchanged.
The backend changes are fully backward compatible. Existing procurement detail, list, supplier, and timeline pages continue to work without modification.
New data & actions for bool-dashboard to consume (no redesign of pages required; enhance existing):
-
On Procurement Detail page (buyer):
- Add "Responses" section below timeline or quotes.
- Call
GET /procurement/:id/responses→ render table: Supplier | Status Badge (✓ Accepted / ✕ Rejected / ↺ Counter Offered / Pending) | Notes | Updated. - Show summary counts (Total Suppliers, Responses Received, Accepted, Rejected, Counter Offers) — visual pills or simple divs, no charts.
- If buyer manager + status allows: "Mark Supplier Selected" button → POST /procurement/:id/select-supplier with chosen supplier_id (from a response row). On success, status becomes completed, refresh timeline.
-
On Supplier Procurement Detail (supplier workspace):
- If the logged-in supplier org matches the request's supplier_organization_id and request is actionable (sent / partially_responded):
- Show action panel (or inline form): Accept / Reject / Counter Offer radio or segmented control + Notes textarea + Submit.
- POST /supplier/procurement/:id/respond .
- On success or if already responded: show current response status + edit form (PATCH /supplier/procurement/responses/:id).
- Hide panel for non-associated suppliers (they see 403/empty).
- If the logged-in supplier org matches the request's supplier_organization_id and request is actionable (sent / partially_responded):
-
Timeline: automatically shows new events ("supplier accepted", "supplier selected", etc.) — no FE change needed beyond perhaps styling new event types.
-
Badges: reuse existing status/badge components (map response status to variants: success=accepted, danger=rejected, warning=counter_offered, muted=pending).
-
Loading/empty: standard patterns (spinner, "No responses yet", "Only the assigned supplier can respond").
-
Error handling: map 409 INVALID_STATE_TRANSITION / PROCUREMENT_LOCKED (existing), 403 permission.
-
No changes to basket→procurement, approvals, quote flow, or payment/ordering (none implemented).
Add the new calls behind feature flag or after migration applied. Backend migration 028 is additive (safe to run on existing DBs).
GET /api/v1/procurement/:id/savings-intelligence returns decision-support data for an existing buyer procurement request. Access uses the same active organization and procurement visibility rules as GET /api/v1/procurement/:id.
The service reuses procurement item snapshots and existing price intelligence/search resolution. Reference price priority is: item snapshot unit price, most recent visible approved contextual price, product intelligence market average, then incomplete when no price exists. Best available price comes from the existing contextual price search and confidence scoring path; prices are never invented.
Item savings are calculated as quantity * reference_price - quantity * best_price, floored at zero. Supplier coverage counts how many request items each supplier can price, estimates supplier basket total from approved visible prices, reports missing items, and averages confidence across covered rows.
Supplier recommendations are deterministic. If any supplier covers all items, full-coverage suppliers are ranked by lowest estimated total, highest confidence, then highest savings. If no supplier covers all items, the score is coverage_percentage * 0.40 + normalized_savings_score * 0.35 + confidence_score * 0.25; ties within 3 points prefer higher coverage, lower estimated total, then higher confidence.
This MVP does not place orders, send RFQs, or communicate with suppliers.
It provides procurement savings intelligence based on approved pricing data.
GET /api/v1/procurement/:id/timeline returns a UI-oriented { "timeline": [...] } payload with event, occurred timestamp, and actor name for created, approved, rejected, sent-to-supplier, supplier-responded, quote-accepted, and quote-rejected events. It does not expose raw audit log rows.
Audit trail:
- Approval, rejection, cancellation, supplier send, quote submission, quote acceptance, and quote rejection write
audit_logsrows with actor user, actor organization, previous status, new status, timestamp, and metadata. - Procurement event hooks exist for
ProcurementSent,ProcurementApproved,ProcurementRejected, andSupplierQuoteSubmitted; current implementations are no-op extension points for email, WhatsApp, push, analytics, and integrations.
QA checklist:
- Create basket.
- Compare basket.
- Convert basket into procurement.
- Submit procurement.
- Approve procurement.
- Send to supplier.
- Switch to supplier workspace.
- View procurement.
- Create quote draft.
- Submit quote.
- Switch back to buyer.
- Verify
supplier_respondedstatus. - Review quote comparison totals and per-item deltas.
- Accept the quote and verify
quote_accepted. - Repeat with a second request, reject with a reason, and verify
quote_rejectedplus audit metadata. - Attempt procurement edits after send and verify immutability enforcement.
- Change live prices and verify procurement snapshot remains unchanged.
- Verify buyer and supplier summary counts are organization-scoped and role-appropriate.
- Run
go run cmd/seed-demo/main.goand complete one buyer to supplier to buyer demo path from seeded data.
QA coverage includes state-machine tests, buyer permission tests, supplier permission tests, organization tenant isolation, supplier isolation, quote flow, audit events, branch-aware basket snapshot conversion, and snapshot immutability checks.
Error responses use a consistent frontend-friendly envelope:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request",
"details": {}
}
}Common error codes:
VALIDATION_ERROR: malformed JSON, missing required fields, invalid enum values, invalid PATCH combinations, or inaccessible related IDs.UNAUTHORIZED: missing or invalid authentication context.FORBIDDEN: authenticated actor lacks workspace, role, supplier, or ownership permission.NOT_FOUND: requested resource is not visible in the active workspace or has been deleted.CONFLICT: duplicate product/vendor identity or another uniqueness conflict.RATE_LIMITED: sensitive endpoint exceeded the lightweight per-client request limit.INTERNAL_ERROR: unexpected server failure. Internal DB details are not returned to clients.
Core records use soft-delete semantics (deleted_at) where supported. Normal list/search flows exclude soft-deleted products, prices, vendors, vendor branches, baskets, and basket items. Vendor branches are also governed by is_active; inactive or deleted branches are hidden from normal branch-aware price flows and cannot be attached to prices. Vendors must be visible, not deleted, and active where new mutations reference them. Historical price, report, request, basket, and audit records may keep references to deleted entities, but new mutations must validate that referenced products/vendors/branches are still usable.
Request logging writes structured request events with request_id, workspace, actor, status, and latency. Sensitive auth and invite endpoints have lightweight per-client rate limiting.
Search filters: product_id, vendor_id, vendor_branch_id, currency, availability_status, source_type, include_expired, verified_only, and limit.
vendor_branch_id is an exact branch filter. A branch-aware product price list includes branch metadata when the price is tied to a branch, while vendor-wide prices keep vendor_branch_id = null.
Example org price:
curl -X POST http://localhost:8080/api/v1/admin/prices \
-H "Authorization: Bearer dev" \
-H "Content-Type: application/json" \
-d '{"organization_id":"{{organization_id}}","product_id":"{{product_id}}","vendor_id":"{{vendor_id}}","price":12.90,"currency":"ILS","availability_status":"in_stock","source_type":"admin"}'Example price report:
curl -X POST http://localhost:8080/api/v1/prices/{{price_id}}/report \
-H "Authorization: Bearer dev" \
-H "Content-Type: application/json" \
-d '{"product_id":"{{product_id}}","vendor_id":"{{vendor_id}}","report_type":"found_cheaper","reported_price":11.90,"currency":"ILS","notes":"Seen cheaper nearby"}'Vendor branches are operational supplier/vendor locations. They are managed through authenticated, active-workspace routes:
GET /api/v1/vendors/:vendorId/branchesPOST /api/v1/vendors/:vendorId/branchesGET /api/v1/vendors/:vendorId/branches/:branchIdPATCH /api/v1/vendors/:vendorId/branches/:branchIdDELETE /api/v1/vendors/:vendorId/branches/:branchIdGET /api/v1/admin/vendors/:vendorId/branchesPOST /api/v1/admin/vendors/:vendorId/branchesPATCH /api/v1/admin/vendors/:vendorId/branches/:branchIdDELETE /api/v1/admin/vendors/:vendorId/branches/:branchId
Platform admins and super admins can manage branches for any vendor. Supplier/vendor owners and permitted managers can manage only branches for vendors owned by their active organization. Normal users and unrelated organization managers cannot create, update, or delete supplier/vendor branches. The API never trusts vendor_id from the request body alone; branch mutations verify the vendor in the URL and the branch's actual vendor ownership.
Deleting a branch is a soft delete. The API sets deleted_at and is_active = false, so normal branch lists stop returning the branch. Historical prices, submissions, audit rows, and other records that reference vendor_branch_id are preserved.
Manual QA:
- As super admin, create, edit, and delete a branch for any vendor.
- As supplier/vendor owner, create, edit, and delete a branch for the owned vendor.
- As supplier/vendor manager, repeat if the role is permitted in the active workspace.
- As normal user, confirm branch create/edit/delete returns forbidden and controls are hidden in the dashboard.
- As supplier/vendor owner, attempt to delete another vendor's branch and confirm forbidden or not found.
- Confirm deleted branches do not appear in
GET /api/v1/vendors/:vendorId/branches. - Confirm prices with
vendor_branch_idremain after branch deletion. - PATCH a supplier price with only
priceand confirm the existingvendor_branch_idis unchanged. - PATCH a supplier price with a valid
vendor_branch_idfor the same vendor/workspace and confirm the branch changes. - PATCH a supplier price with
clear_vendor_branch: trueand confirmvendor_branch_idbecomesnull. - PATCH a supplier price with both
vendor_branch_idandclear_vendor_branch: trueand confirm validation error. - PATCH a supplier price with another vendor/workspace branch and confirm validation error or forbidden.
- As an unauthorized role, PATCH supplier price branch data and confirm forbidden.
Workspace and role isolation QA:
- Call any
/api/v1/...workspace-scoped endpoint without a valid access token and active token organization and confirm unauthorized or forbidden. - As a user with active workspace A, call
/api/v1/orgs/{workspaceB}and confirm forbidden. - As a user with active workspace A, PATCH
/api/v1/orgs/{workspaceB}/profileand confirm forbidden. - As a user with active workspace A, POST an invite to
/api/v1/orgs/{workspaceB}/invitesand confirm forbidden. - As vendor/workspace A, try to update or delete vendor B's branch and confirm forbidden or not found.
- As vendor/workspace A, try to attach a supplier price to vendor B's branch and confirm validation error or forbidden.
- As a buyer/viewer role, call supplier/admin mutation endpoints and confirm a consistent forbidden response.
- As platform admin or super admin, confirm role-gated admin routes pass middleware, then confirm service-level ownership/global-scope rules still apply.
Price confidence QA:
- Compare a verified supplier branch price captured this week against an unverified supplier price and confirm the verified price shows
highconfidence. - Confirm a recent unverified supplier price shows
mediumorhighconfidence based on branch/unit completeness. - Confirm an old unverified price older than 30 days shows
lowconfidence. - Confirm a branch-specific price scores higher than an otherwise equivalent vendor-wide price.
- Confirm basket comparison selected prices and price matches include both
confidence_scoreandconfidence_label.
Before running the demo seed, repair supplier vendor links first:
go run ./cmd/repair-supplier-vendorsThen confirm these vendor IDs exist:
- Rami Levy:
e2fca9cb-f749-489e-a018-4eae6b422db8 - שופרסל דיל:
0d454d5f-0aef-4157-a216-f989a27169b3 - יוחננוף:
affbe680-163b-4fa9-80c2-3a3dfdc16391
Then run:
go run ./cmd/seed-demoThe command seeds ~25 global Hebrew products and ~75 demo prices visible to all users.
It is safe to re-run because all generated records are idempotent and tagged with:
{"demo": true, "seed": "hebrew-supermarket-demo"}.
QA: After running:
- Search for חלב
- Search for קולה
- Open product price comparison
- Confirm all 3 vendors show prices
- Re-run seed and confirm duplicates are not created
An organization is the workspace/account boundary for users, memberships, and permissions. A vendor is the commercial seller or supplier entity used for price comparison.
Supplier-style organizations should have one linked vendor row:
organizations.id -> vendors.organization_id.
If production has old supplier organizations without linked vendors, run:
go run ./cmd/repair-supplier-vendorsThe repair command creates missing vendor rows or links an existing matching global vendor. It does not create organizations, products, prices, or branches.
The basket comparison MVP answers one question: where is the full basket cheapest from a single vendor or vendor branch. It does not split baskets across vendors, add travel or delivery costs, create purchase orders, perform procurement approvals, or convert currencies.
Authenticated active-workspace routes:
POST /api/v1/baskets/comparePOST /api/v1/baskets/:id/compare
Ad-hoc compare request:
{
"currency": "ILS",
"items": [
{
"product_id": "00000000-0000-0000-0000-000000000001",
"quantity": 2,
"unit_label": "unit"
},
{
"product_id": "00000000-0000-0000-0000-000000000002",
"quantity": 1,
"unit_label": "pack"
}
]
}Response:
{
"currency": "ILS",
"item_count": 2,
"results": [
{
"vendor_id": "00000000-0000-0000-0000-000000000010",
"vendor_name": "Rami Levy",
"currency": "ILS",
"total": 42.5,
"matched_item_count": 2,
"missing_item_count": 0,
"is_complete": true,
"items": []
}
],
"missing_items": [],
"warnings": [
"No currency conversion was performed.",
"Unit conversion is not applied in this MVP."
]
}Comparison uses only visible active-workspace prices: public/global prices plus prices scoped to the active organization. Other organizations' private prices are not visible through the existing price search rules. Expired and out-of-stock prices are excluded. Prices are filtered by the requested currency (ILS, USD, or EUR), defaulting to ILS; no conversion is performed. Duplicate product lines are merged by summing quantities, and the MVP supports up to 50 items.
Error, validation, and soft-delete QA:
- Submit malformed JSON and invalid enum values to product, price, branch, and basket endpoints and confirm
400witherror.code = VALIDATION_ERROR. - Call a workspace-scoped endpoint without auth and confirm
401witherror.code = UNAUTHORIZED. - Call a mutation endpoint with a viewer/buyer role and confirm
403witherror.code = FORBIDDEN. - Request a deleted or cross-workspace entity and confirm
404or403without internal DB details. - Attempt duplicate product/vendor creation and confirm
409witherror.code = CONFLICT. - Soft-delete a product and confirm it is absent from normal product search and does not appear as an active basket comparison match.
- Soft-delete or deactivate a vendor branch and confirm it cannot be attached to a price.
- Suspend/inactivate a supplier vendor and confirm supplier workspace prices remain hidden from buyer search.
- Hit auth or invite endpoints repeatedly and confirm
429witherror.code = RATE_LIMITED. - Confirm responses include
X-Request-IDand request logs include request id, workspace id, actor id, status, and latency.
Current architecture:
- Models:
internal/models/procurement_request.go,internal/models/procurement_request_item.go,internal/models/supplier_quote.go,internal/models/audit_log.go. - DTOs:
internal/dto/procurement_request_dto.go. - Service:
internal/services/procurement_request.goowns workflow validation, tenant checks, quote validation, snapshot construction, audit metadata, and event hook calls. - Repository:
internal/repositories/procurement_request.goowns scoped database reads/writes and transactional quote/status persistence. - Handler/routes:
internal/handlers/procurement_request_handler.goand/api/v1/procurement,/api/v1/baskets/:id/create-procurement,/api/v1/supplier/procurement. - Migrations:
migrations/016_procurement_requests.up.sqlandmigrations/020_procurement_snapshots_quotes_audit.up.sql.
Workflow state machine:
draft -> pending_approval
pending_approval -> approved
pending_approval -> rejected
approved -> sent_to_supplier
approved -> cancelled
sent_to_supplier -> supplier_responded
sent_to_supplier -> cancelled
All other transitions return INVALID_STATE_TRANSITION. Rejected and cancelled requests are terminal. Commercial updates after draft return PROCUREMENT_LOCKED; after sent_to_supplier, commercial request items and selected supplier/vendor/branch data are immutable and detail views must use procurement_request_items snapshot fields rather than live product, price, vendor, or branch tables.
Supplier quotes are scoped by active supplier organization membership. Suppliers can save draft quotes for requests visible through /api/v1/supplier/procurement; submitted quotes cannot be edited. Quote item IDs must belong to the procurement request. The backend calculates line totals, subtotal, discount total, tax total, grand total, and legacy total_amount; client-provided totals are ignored.
Audit actions emitted by procurement code are submitted_for_approval, approved, rejected, sent_to_supplier, cancelled, and quote_submitted. No-op domain event hooks exist for ProcurementCreated, ProcurementSubmitted, ProcurementApproved, ProcurementRejected, ProcurementSent, and SupplierQuoteSubmitted; these are extension points only and do not send notifications.
Baskets are owned by the active workspace organization, not by an individual user. Basket item rows now preserve commercial selection snapshots: product/vendor/branch names, selected price id, currency, unit label, unit price, line total, and notes. Basket detail responses calculate supplier groups and totals on the backend; clients display those values and must not recalculate authoritative totals.
Basket lifecycle:
active: editable; items, quantities, and notes can change.converted: all active items were converted into procurement requests; basket is read-only.archived: historical read-only state.
One procurement request may target only one supplier group. POST /api/v1/baskets/:id/create-procurement requires a selected vendor and converts only matching basket items. Conversion runs in one database transaction: create procurement request, create procurement item snapshots, soft-delete converted basket items, update basket status to converted only when no items remain, and write the audit event. If any step fails, including a concurrent basket update, the request and item removals are rolled back together.
Partial conversion is intentional. If a basket contains Supplier A and Supplier B items, converting Supplier A removes only Supplier A rows and leaves Supplier B rows active for a later request.
Procurement item snapshots are authoritative. Product, supplier, branch, selected price, currency, selected unit price, confidence, source type, captured time, quantity, and unit labels are copied into procurement_request_items at creation time. Procurement detail and supplier quote flows must render those snapshot columns rather than live product, vendor, branch, or price records.
Price freshness is warning-only. Basket comparison vendor totals expose price_changed and stale_snapshot when a saved basket price differs from or no longer matches the current visible price record. Procurement creation does not block on those warnings; it stores warning metadata such as price_changed, stale_snapshot, and the old/new price when available.
Procurement QA checklist:
- Create a basket, compare suppliers, create a procurement request, submit, approve, send, save quote draft, edit draft, submit quote, and confirm buyer sees
supplier_responded. - Try duplicate and invalid transitions from every status and confirm
INVALID_STATE_TRANSITION. - Try editing supplier/vendor/branch/items/quantity/price/currency after send and confirm
PROCUREMENT_LOCKED. - Change products, prices, vendors, and branches after send and confirm procurement detail still renders historical snapshot values.
- Change or delete a selected basket price after comparison and confirm warning metadata is returned without blocking procurement creation.
- Convert one supplier from a multi-supplier basket and confirm unrelated supplier rows remain active; convert the final supplier and confirm the basket becomes
converted. - Verify wrong buyer org, wrong supplier org, personal/no-org users, inactive members, and platform admins outside ownership cannot access or mutate scoped procurement records.
- Confirm audit rows exist for approval, rejection, send, cancellation, and quote submission actions.
The core architecture introduces mandatory tenant-isolated in-app notification tracking system for business events across supply channels.
- Mandatory Dashboard Tracking: Every system milestone must write directly to the database. External communication deliveries (SMS, Email, WhatsApp) are reserved for downstream decoupled event-consumers and must not bypass or substitute dashboard tracking.
- Strict Tenant Scoping: All notifications explicitly belong to an
organization_idAND a targetedrecipient_user_id. Queries implicitly strip exposure to any foreign entity rows. Global contexts without org allocations are rejected by schema invariants.
| Source System Event Key | Severity | Recipient Roles Target | Context Visibility Area |
|---|---|---|---|
procurement_submitted_for_approval |
info |
Buyer: owner, admin, manager |
/app/procurement/{id} |
procurement_sent_to_supplier |
info |
Supplier: All Operational Roles | /app/supplier/requests/{id} |
procurement_supplier_response_created |
info |
Buyer: owner, admin, manager |
/app/procurement/{id} |
procurement_supplier_selected |
success |
Supplier: Winning Organization Members | /app/supplier/requests/{id} |
procurement_completed |
success |
Buyer: owner, admin, manager |
/app/procurement/{id} |
GET /api/v1/notifications- Retrieve list payload filtered to active user context (Default limit: 20).GET /api/v1/notifications/unread-count- Immediate lightweight count of pending actions.PATCH /api/v1/notifications/:id/read- Flags active row context tracking toreadstatus.PATCH /api/v1/notifications/read-all- Contextually clears visibility backlogs for active organization view.PATCH /api/v1/notifications/:id/archive- Permanently conceals item from standard workflows.
In-app notifications drive user attention for actionable events (status changes, moderation, invites, relationships). They are not audit logs or analytics.
Current State (this branch / Phase 1):
Wired and functional (real notifications are created):
- All existing procurement notifications (created, submitted for approval, approved, rejected, sent to supplier, supplier response created/updated, quote accepted/rejected, completed, cancelled, supplier selected, etc.).
- Product submitted for review (when a user submits a candidate via
POST /products/submit-candidate). - Supplier quote submitted (buyer managers are notified when a supplier submits a quote).
- Procurement cancellation (both buyer and supplier organizations are notified when applicable).
Infrastructure complete, but not yet wired into flows:
- Notification types and English/Hebrew message templates exist for: product approved/rejected/merged, price submitted/approved/rejected/verified, organization invite accepted, role changed, member removed, basket needs attention, supplier created, supplier price confirmation requested/confirmed/rejected, etc.
- Helper functions exist (
services.Notify*). - Localization (EN/HE via organization
default_language) works for any wired notification.
Planned for follow-up PRs (not implemented here):
- Wire product approved / rejected / merged notifications.
- Wire price submission + approval/rejection/verification notifications.
- Wire organization invite accepted, role changed, member removed.
- Wire
basket_needs_attentiononBasketIncompleteError. - Wire supplier/vendor related notifications where clear flows already exist.
Technical details:
- All notification titles/bodies are built server-side via
internal/notifications/messages.go(supports "en" / "he"; falls back to English; simple {placeholder} interpolation). - Locale selection: organization
default_languagefor role-based batches. - Buyer routes use
/app/procurement/{id}. Supplier routes use/app/supplier/requests/{id}. - Adding a new type safely: 1) append const in
models/notification.go2) add EN+HE templates innotifications/messages.go3) use/extend helper inservices/notification_helpers.go4) wire after success in an existing flow (never inside tx, never return error) 5) test with capturing notifier.
Rule: Notifications = user attention. Audit logs = historical record. Analytics = behaviour/stats.
See internal/notifications/messages.go, internal/services/notification_helpers.go, and internal/services/procurement_request.go for the current implementation. The frontend notification bell renders the server-provided localized title/body.