Vendors
What it is
Vendors are third-party service providers (caterers, decorators, etc.) that planners can discover, book, and rate for their events. The vendor surface spans three modules: vendorProfile (a vendor's storefront — categories, search, listings, admin approval), vendor (booking a specific vendor into a specific event — offers, waitlist), and vendorRatings (post-event reviews feeding a weighted score).
Core concepts
- A vendor profile must be admin-approved before it's discoverable —
isApproved: false, rejectedAt: nullmeans pending;isApproved: truemeans live;rejectedAtset means rejected (withapprovalNotesexplaining why). - Vendor discovery supports both traditional filtering (
category/:categorySlug,trending/category/:categorySlug) and AI-powered vector/semantic search (vector-search,smart-search) backed by Qdrant embeddings — seeupdateVendorEmbedding/updateAllVendorEmbeddings. - Ratings are weighted by how much was actually paid to the vendor for that event, not a plain average — see below.
How it works
Approval flow
- Vendor profile is created (unapproved by default).
- Admin lists pending profiles:
GET /vendor-profile/admin/pending(paginated, newest first). - Admin approves (
POST /vendor-profile/profile/:profileId/approve, withpermissionsand optionalapprovalNotes) or rejects (setsrejectedAt+approvalNotes). - Only approved profiles surface in public category/search/trending listings.
Weighted ratings
Simple average rating treats every review equally regardless of the size of the booking behind it. This system weights each rating by the actual amount paid to the vendor for that specific event:
weightedAverage = Σ(ratingValue × totalAmountPaid) / Σ(totalAmountPaid)
If total paid across all ratings is zero, it falls back to a simple average. Example: a 3★ review on a ₦1,000 booking and a 5★ review on a ₦5,000 booking average to 4.0 simple, but 4.67 weighted — the higher-value booking's rating counts for more, on the reasoning that performance on a bigger contract is more informative than performance on a small one.
totalAmountPaid per rating isn't stored statically — it's recalculated at rating submission/update time from actual transactions records (WHERE referenceTag LIKE 'evt:{eventId}-vdr:{vendorId}%' AND direction = 'DEBIT' AND transactionStatus = 'Completed'), so it stays accurate even if more payments land after the rating was first submitted. The same recalculation also updates vendors.amountPaid, keeping both in sync whenever a bank-transfer payment to a vendor completes (PartybankService.updateVendorAmountsAfterPayment).
Booking a vendor into an event
Two independent paths, not one funnel:
Direct add — a planner already knows who they want and adds them straight in: POST /vendor/add-vendor/event/:eventId (manual entry) or POST /vendor/add-from-profile/event/:eventId (from an existing approved vendor profile). No negotiation step; the vendor is just attached to the event.
Listing + offer marketplace — for planners who want vendors to bid:
- Planner creates a listing describing what they need:
POST /vendor/event/:eventId/listing. - Vendors submit offers against that listing:
POST /vendor/:listingid/offer. - Planner reviews offers (
GET /vendor/event/:eventId/offers) and accepts or rejects one:PATCH /vendor/offer/:id/acceptance?status=accepted|rejected—acceptanceStatusis one ofpending/accepted/rejected(ACCEPTANCE_STATUSenum).
Separately, a prospective vendor who isn't onboarded yet can express interest via POST /vendor/waitlist (public, no auth) — this is lead capture, not a booking; GET /vendor/waitlist (admin-only) is how the team works the list.
Once a vendor is attached to an event (either path), PATCH /vendor/:id/payment-info records how they get paid, and PATCH /vendor/event/:eventId/vendor/:id/toggle-promotion controls whether they show as a "promoted" vendor for that event's public page.
Vendor metrics (admin, background)
A separate batch job (vendorMetrics module) refreshes aggregate vendor performance metrics (ratings, activity status, booking counts) for all vendors — built specifically to stay within App Engine's 30-second request timeout at scale (100k+ vendors, batches of 20 via Google Cloud Tasks, ~8-10 minutes end to end). Admins start/monitor/cancel jobs; Cloud Tasks calls the internal batch-processing endpoint, authenticated via cloudTaskMiddleware rather than a user JWT.
Key business rules & edge cases
- A rejected profile isn't deleted —
rejectedAt+approvalNotesare kept, presumably so the vendor can see why and reapply/appeal. - The weighted-rating recalculation runs on every rating submission or update — if you're bulk-importing historical ratings, expect a matching burst of
transactionsqueries, not a cheap batch write. GET /vendor-profile/admin/pendingincludes guest-identifying fields (phoneNumber,email) directly in the response — access is admin-gated, but be deliberate about not exposing this endpoint's shape elsewhere.
Related code
src/modules/vendorProfile/vendorProfile.routes.ts,vendorProfile.controller.ts,vendorProfile.service.tssrc/modules/vendor/vendor.routes.ts,vendor.controller.ts,vendor.service.ts— direct-add, listing/offer marketplace, waitlistsrc/modules/vendorRatings/vendorRatings.routes.ts,vendorRatings.service.ts— weighted average logic,getWeightedAverageRatingForVendorProfilesrc/repositories/vendor-profile.repository.ts,src/repositories/transaction.repository.ts—getTotalAmountPaidToVendorForEventsrc/modules/vendorMetrics/— batch metrics job (VendorMetricsService, Cloud Tasks integration)src/shared/enum/generic.enum.ts—ACCEPTANCE_STATUS
Not yet in the generated OpenAPI spec — this module hasn't been through the pilot OpenAPI pass.