A customer portal where buyers request samples and track shipping

Buyers sign in to request samples and check shipping, while staff review every request in one list. The steps from sample request to shipping work on test data. The portal is still being built; order and quote forms come next.

Client: Copperstill IngredientsB2B supplier · Bulk spirits

CreditDesigned and built by our founder, as lead developer for a partner agency.

Follow one sample request

The buyer follows the request while staff decide each step

Both sides see the same request, under one reference number.

Sample requestSR-2026-0001

Staff asked for a delivery phone number and the buyer replied. Staff then approved and shipped request SR-2026-0001. The buyer can see the carrier and tracking number TRACK-0001. Private staff notes are not shared.

About Copperstill Ingredients

When a buyer asked about an order, someone had to look it up in a spreadsheet and reply.

Copperstill Ingredients sells bulk spirits to licensed businesses in the US, in anything from a 4 oz sample bottle to a tanker. Most buyers order again and again. Staff kept each request's stage, tracking number, reminders and notes in one master spreadsheet, and customers had nowhere to sign in.

Client
Copperstill IngredientsA US bulk spirits supplier selling to licensed businesses
Industry
Bulk spirits · United States
Buyers
Licensed businesses, often repeat customers
Sales process
Samples, quotes and repeat orders
Portal interface: a buyer answers a delivery-phone question on request SR-2026-0001. Staff see the reply and a separate internal note that the buyer cannot see.
The buyer and staff see the same question.The buyer's reply is saved with the request. Internal staff notes are not shown to the buyer.

The problem

Buyers had to ask staff for every update

  • Buyers emailed or phoned to check on orders

    To find out whether an order had shipped, a buyer had to email or phone someone.

  • Staff typed each request into a spreadsheet

    Every request had to be copied into the spreadsheet by hand, then followed up.

  • Buyers could not see their history

    There was no customer account showing what the company had asked for or received.

  • Permit paperwork was tracked by hand

    Some orders must wait for the buyer's permit paperwork, and staff tracked these holds and reminders by hand. The product list also had to be updated in two places.

What was built

An account for buyers and a review list for staff

  • Buyers and staff follow the same request

    A saved draft becomes a request with its own reference number. Buyers can answer questions and see shipping details, while staff move the same request through review.

  • A company account for the whole team

    Buyers sign in with email or Google. Company admins invite or remove colleagues, and access depends on being a member of that company.

  • The full product list is ready to choose from

    The catalogue holds 41 products in 7 categories and 6 container types. The sample form records products, sizes, quantities and a delivery contact.

  • Staff work from one list

    Filters help staff find requests. They can ask buyers questions, keep private notes, record payments and add the carrier and tracking number.

Control and safety

Staff decide when a request moves forward

People review, approve and mark requests as shipped. Only the emails and one reminder are automatic. If a buyer does not answer a question, the request goes onto a staff follow-up list; it is not closed automatically.

  • Company access is checked in several places. Both the app and the database check which company a user belongs to and their role. Tests try to reach a second company's records through pages, actions and downloads.
  • Private staff notes are not shown to customers. A test checks that private notes do not reach customers. Actions staff take for a buyer are logged under the real staff member's name.
  • Files are checked before use. Uploads are limited to 25 MB and six file types. The server checks what each file really contains, and every download goes through a route that checks access.
  • Removing a colleague ends their access. Removing someone inside the portal takes effect on their next request. Removing them at the sign-in provider took about 68 seconds in three timed tests.

For technical readers

How the system works

One app serves both buyers and staff. Each action checks who is signed in and their role, then works only with that company's data. A change is saved before the email about it is sent.

Buyer or staffThe same portal
Check sign-inClerk and portal membership
Check the actionRole, company, input
Save the changeOnly that company's data
Private filesAccess-checked downloads
Send the updateQueued, then sent by Resend

Dashed lines show supporting connections, or parts that are paused or not yet built.

In more detail

Technology used (10 parts)Each part of the system, and why it was chosen.
LayerTechnologyWhy it was chosen
InterfaceNext.js 16, React 19, Tailwind 4, shadcn/RadixOne app serves the customer and staff screens, with accessible controls and touch targets of at least 44px.
ActionsNext.js server actions and route handlers, Zod 4Each input is checked, and unknown fields are rejected, before any work runs.
DatabaseSupabase Postgres, Drizzle ORM, 37 migrationsDatabase rules check the company and environment, as well as the app's own filters.
Sign-inClerkHandles email and Google sign-in and invitations. Membership and role are also stored in the portal's own database.
Background workVercel Cron and queue-page sweepsDaily jobs and short checks retry waiting emails and send reminders.
EmailResendSeparate log, test-list and send modes keep test emails apart from real ones.
FilesPrivate Supabase Storage, file-typeUploads go straight to private storage and are checked by their content; downloads go through a route that checks the user is signed in.
HostingVercelPreview, staging and live settings are kept separate, and every database row records its environment.
ChecksGitHub Actions, Biome, oxlint, TypeScript 7Code style, types, the build and the database schema are checked on every change.
TestsVitest, Playwright, Postgres 17 in CITests use a real database and browser sessions for two made-up companies to check the access rules.
5 engineering decisions that matteredThe problem, what was done, and what it changed.
  1. A forgotten filter must not show another company's data

    One helper sets the company, user, role and environment for each database transaction. Database rules require that information, and the app uses a database account with limited rights. The app's own filters stay as an extra check.

  2. Access is removed in the portal first

    When someone is removed, the portal first marks their membership as revoked. Every request reads that record, so their next request is refused. The call to the sign-in provider is retried afterwards. Signed timestamps on events stop an older member-added event from restoring removed access.

  3. Every download is checked

    Files are sent through a route that checks access, rather than through a reusable download link. Uploads go straight to private storage using a one-time link, and then the server checks the real file type. A PNG image renamed to look like a PDF is not accepted.

  4. A change and its email are saved together

    A status change saves a waiting email in the same database transaction, and the email is sent afterwards. Background workers pick up emails without overlapping, and a key for each recipient prevents duplicate emails when a send is retried. Updates about a request that has since closed are skipped.

  5. Samples, orders and quotes share one structure

    Samples, orders and quotes share one requests table. Fields specific to each form use a versioned layout, so old requests stay readable. Both the app and the database control which status changes are allowed. Only the sample form is built in this record.

Testing and build recordHow it was tested, and the build by the numbers.

Quality and reliability

  • The last recorded full local run passed 771 unit tests. A further 195 database tests run against Postgres in the automated build checks (CI); the source describes 960+ automated tests in total, plus browser checks.
  • Browser tests (Playwright) in 18 files sign in as real test users of two made-up companies, including checks against direct links, actions and downloads.
  • Three timed tests of removal at the sign-in provider first refused access after 67.6, 67.8 and 69.1 seconds, checking every 10 seconds. Removal inside the portal is different: it takes effect on the next request.
  • The automated checks also confirm that the 37 migrations still match the database schema, alongside code style, types, build, unit, database and browser tests.
  • Failed emails retry six times, with waits of 1, 2, 4, 8 and 16 minutes. Stored error codes leave out email addresses and secrets.

By the numbers (build)

  • 258 commits and about 50 pull requests from 20 September to 3 October, around 65,000 lines of TypeScript.
  • 37 database migrations and 80+ database access rules (row-level security).
  • 4 roles and 3 request types in one model; only the sample form is built.
  • 16 days from the walkthrough to a support conversation built and tested on the staging version, not yet released.

Is it a fit?

Could this work for your team?

  • You supply businesses that order again and again. Buyers ask for samples, quotes and orders by email or phone.
  • Your team tracks requests in a spreadsheet. Staff copy details between tools and answer the same shipping questions.
  • A person needs to check requests. Paperwork, payment or delivery details decide when a request can move forward.
  • Your products and rules are different. We can adapt the catalogue, units, request fields and email wording, and connect the portal to your stock and pricing systems.

Do buyers still email to ask where their order is?

Conducted by Apurva · Not a sales call