Dentolize · Suppliers (Marketplace) Walkthrough
On this pageBusiness viewTechnical view

Seller Portal: Listings, Tiers & Quotas

Business view

Every supplier gets its own login to a dedicated app — supplier-web, branded "Supplier Portal" — separate from the clinic/pharmacy product. A supplier's staff log in with a company login name, username, and password (same two-step flow as the pharmacy app), and land on a dashboard showing at a glance: how many products they've listed, how many are currently active, how many orders are open vs. delivered, and how many RFQ invitations they've received.

From there, the two things a supplier does day-to-day are:

  • Manage listings — what they sell, at what price, with what minimum order quantity and lead time.
  • Watch the order inbox — see what buyers have ordered and move each order through fulfilment.

Both RFQs (quote requests from buyers) and the standalone accounting view are paid-tier features: a supplier on the free/starter plan sees them in the navigation as locked, with an upsell message, until they upgrade. This is the plan/tier system: suppliers sign up on a plan (tiers 0–3), and each capability in the portal is unlocked at a specific tier. A plan also caps how many listings a supplier can publish — trying to add one more past the limit is blocked with a clear "upgrade your plan" message.

Technical view

The app

packages/supplier-web is a new Vite + React + Apollo SPA, copied from the existing pharmacy-web app's architecture (same session provider, same UI kit, same permissions pattern). Dev server runs on port 3020 (packages/supplier-web/vite.config.ts), proxying /graphql to the API server. Branding is deliberately placeholder: SUPPLIER_BRAND in packages/supplier-web/src/brand.ts sets displayName: 'Supplier Portal' and an empty hostnames: [], with a comment that the real trade name is pending registrar approval — no production subdomain is configured yet, which is why it isn't reachable in the sandbox (see Walkthrough).

Routes (packages/supplier-web/src/App.tsx): /login, then behind session auth, / (Dashboard), /listings, /orders, /rfqs, /accounting, all wrapped by a shared AppShell (icon rail + content area).

Two independent gating systems

This module layers two separate checks, and both must pass:

  1. Permissions (lib/permissions.ts) — per-user role flags computed from user.group.permissions, e.g. viewListings, addListings, editListings, deleteListings, viewOrders, manageOrders, viewRfqs, submitOffers. This answers "can this staff member do this," same as any other Dentolize permission group.
  2. Entitlements (SupplierEntitlements, fetched via the supplierEntitlements query) — per-company subscription-tier flags: accounting, bulkImport, rfqRespond, analyticsBasic, featuredListings, multiUser, plus maxListings/listingCount. This answers "does the vendor's plan include this feature at all."

Both the RFQs and Accounting nav items independently re-fetch entitlements and render a locked empty state even if reached directly by URL — not just hidden from the nav (packages/supplier-web/src/modules/rfqs/RfqsPage.tsx, .../accounting/AccountingPage.tsx). The Accounting page itself is currently a stub — a single "provisioned" confirmation card, no real ledger UI yet.

Entitlement tiers (packages/server/src/services/trade/entitlements.js)

A vendor's effective tier (0–3) comes from its VendorSubscription.plan.tier, downgraded to tier 0 automatically if the subscription isn't ACTIVE/PAST_DUE/live-TRIAL — "so in-flight orders can still close" rather than a hard lockout (entitlements.js:62-63). Feature → minimum tier (VENDOR_FEATURE_DEFAULTS, entitlements.js:15-38):

TierFeatures this PR uses
0 (free)INVENTORY, ORDERS, LISTINGS — the core listing/order functionality is not gated
1ACCOUNTING, BULK_IMPORT, RFQ_RESPOND, ANALYTICS_BASIC, MULTI_USER
2FEATURED_LISTINGS (add-on)

hasVendorEntitlement(prisma, companyId, key) checks, in order: a per-vendor hard revoke override → hard grant override → a purchased à-la-carte add-on → else tier >= minTier. checkVendorQuota(prisma, companyId, quotaKey, currentCount) resolves maxListings/maxImagesPerListing the same way (override wins over the plan field; null = unlimited).

Every gated resolver re-checks server-side — the UI checks are for user experience, not enforcement:

  • submitSupplierOffer mutation (packages/server/src/resolvers/mutations/supplierMutations.js:30-36) throws "Responding to RFQs requires the Growth plan or higher." if RFQ_RESPOND isn't entitled — even if the user's role permission (SUBMIT_VENDOR_OFFERS) would otherwise allow it. This is deliberate: "a tier downgrade locks bidding even for a permitted user" (file header comment).
  • supplierRfqs/supplierRfq queries (resolvers/queries/supplierQueries.js:33-42) silently return []/null if not entitled, rather than erroring.

A gap worth flagging: the entitlement registry also defines RFQ_BROADCAST_CITY (tier 1) and RFQ_BROADCAST_REGION_ALL (tier 2) — presumably meant to gate how widely an RFQ can reach a vendor by geo-broadcast. But offerService.js's reachableRfqIds (which decides which broadcast RFQs a vendor can see) never checks either key — any vendor whose service area geo-matches an open broadcast RFQ can see and bid on it, gated only by the flat RFQ_RESPOND check, regardless of broadcast tier.

Listing CRUD (packages/server/src/supplier/listingService.js)

Every read and write is scoped by vendorCompanyId — a vendor can never touch another vendor's listings, enforced by findFirst({ where: { id, vendorCompanyId: companyId } }) before every update/delete (listingService.js:50-53, 111-125, 127-140, 142-154).

  • Create (createSupplierListing, listingService.js:89-109): counts existing listings, calls checkVendorQuota(..., 'maxListings', count); if not allowed, throws ` Listing limit reached (${quota.limit}). Upgrade your plan to add more listings. `. This is a hard block — contrast with images below.
  • Images: sanitizeImages calls checkVendorQuota(..., 'maxImagesPerListing', 0) and, if over the limit, silently truncates the list to the limit (Array.slice) rather than erroring — an intentional asymmetry: the listing count is hard-blocked, the image count is soft-clamped.
  • Delete (deleteSupplierListing, listingService.js:142-154): if the listing is referenced by any historical TradeOrderLine (i.e., it was ever ordered), it's soft-retired (active: false) instead of hard-deleted, to preserve order history. Otherwise it's hard-deleted. Both paths return true — the caller can't tell which happened from the return value alone.
  • Defaults applied on create/update: type: 'PRODUCT', price: 0, currency: 'SAR', moq: 1, packSize: 1, availability: 'IN_STOCK', active: true (listingService.js:71-87).

The Listings page (ListingsPage.tsx)

Table of the vendor's own listings — Name, SKU, Type, Price, Availability (a toggle button flipping IN_STOCK ⇄ OUT_OF_STOCK), Status badge, Edit/Delete actions. The "+ Add listing" button is disabled with a tooltip once listingCount >= maxListings, and an inline red banner explains why. The add/edit modal covers every field in SupplierListingInput: name (+ Arabic name), type, SKU, price, currency, MOQ, pack size, availability, lead time, active flag.

Order inbox

See Order Lifecycle & Accounting for the full seller state machine and how fulfilment posts to the books. On the Orders page, filter tabs mirror the state machine (All / Placed / Accepted / Preparing / Shipped / Delivered); clicking a row opens a detail drawer with line items, totals, delivery address, and notes, plus one button per currently-valid next status.

RFQs and offer submission

See RFQs & Offers.