In most call centers, QA means a manager listens to a small handful of calls and hopes they're representative. The rest go unreviewed.
CallAudit-X is my attempt at a different setup: upload call recordings, get a transcript and a QA audit from AI, then let a human approve or correct the result. It's a SaaS-style app with customer and admin dashboards, analytics and billing.
Repo: https://github.com/mafzalkalwardev/CallAudit-X (MIT licensed)
This post covers the design decisions I care most about, based on what's in the repo.
The stack
Next.js 14 App Router, React, TypeScript and Tailwind CSS on the front, Prisma with PostgreSQL for data, bcryptjs and jose JWT for auth, Recharts for dashboards, Stripe for billing, OpenAI for transcription and audits, and FormSubmit for password reset email.
Nothing exotic. I wanted every moving part to be something a client's team could maintain.
Decision 1: AI only runs when you ask for it
A lot of AI apps quietly call a model during page render. That's expensive, slow and hard to reason about. In CallAudit-X, OpenAI is never called during page render. It only runs from an explicit upload-and-analyze path:
- The customer uploads audio to
/api/calls/upload. - The customer triggers analysis through
/api/ai/analyze/[callId]. -
lib/ai/analyze-call.tstranscribes and audits with OpenAI, only whenOPENAI_API_KEYexists. - If OpenAI is missing or fails, mock transcript and report data is generated.
- Completed reports are cached in the database and reused by pages.
- Reviewers approve the AI output or submit a corrected category and feedback.
Step 5 means a report is generated once and read many times. Step 6 is the part I think matters most, and I'll come back to it.
Decision 2: a mock AI fallback
The env file treats OpenAI as optional:
OPENAI_API_KEY=""
OPENAI_TRANSCRIPTION_MODEL="gpt-4o-transcribe"
OPENAI_AUDIT_MODEL="gpt-4.1-mini"
Without a key, upload and analyze still work with mock AI, so the app stays demo-ready. If the real call fails, the same fallback kicks in instead of leaving the user with a broken page.
This came out of a practical problem. When you're showing software to a client, you don't want the demo to depend on a key, a quota or a network call behaving perfectly. The fallback lets the whole flow be walked through end to end, and the real model slots in when it's configured.
Decision 3: not every call is a conversation
Anyone who has worked near outbound calling knows that a big share of "calls" never become conversations. CallAudit-X has explicit no-live-conversation handling for:
- voicemail
- no answer
- mailbox full
- beep tone
- spam
- wrong number
Instead of forcing a sales or support score onto a voicemail greeting, these get N/A handling. Scoring a 10-second beep as a "bad sales call" would make the analytics useless, so this is a correctness feature more than a nice-to-have.
The transcript viewer is defensive in a similar way: it handles transcript data as a string, an array, an object or null. AI output and imported data don't always arrive in the shape you expect, and the UI shouldn't crash because of it.
Decision 4: humans stay in the loop
AI audits are a first pass, not a verdict. Reviewers can approve a report, or reject it and submit a corrected category and feedback. The demo flow in the README shows this on purpose: approve one report, then reject and correct another to show the feedback loop.
The analytics page then shows DB-backed charts for category, sentiment, score and verification. Verification status being on the chart is deliberate. It tells you how much of what you're looking at has actually been checked by a person.
Decision 5: boring, careful auth
Auth uses real customer and admin logins with bcrypt password hashes and HTTP-only JWT cookies, with role-protected dashboards.
The password reset flow is where I spent extra care:
-
/forgot-passwordalways returns the same message ("If an account exists, a reset link has been generated."), so it can't be used to check which emails have accounts. - When an account exists, the API creates a random token, stores only its SHA-256 hash, sets a 15-minute expiry and logs the request.
- If
FORM_SUBMIT_ENABLED=true, the reset email goes out through FormSubmit. - In development, if email is off or fails, the reset URL is shown for testing. In production, reset URLs are never returned in the API response.
-
/reset-password?token=...validates the hash and expiry, hashes the new password, clears the token, logs completion and sends the user back to login.
Admins also get a security log table alongside user CRUD, category management and a payments view.
Decision 6: billing that fails safely
The billing page shows the current plan, selected plan, usage, invoices, payment status and per-minute audit categories. The repo defines categories such as Sales QA, Support QA, Lead Qualification, Compliance Review, Appointment Review and Spam Detection, each with its own per-minute rate.
If Stripe keys or real Price IDs are missing, checkout buttons stay disabled and say "Stripe checkout is not configured yet." Same idea as the AI fallback: a half-configured app should say what's missing, not throw.
Running it locally
From the README:
npm install
npm run prisma:generate
npm run prisma:migrate
npm run prisma:seed
npm run dev
Then open http://localhost:3000. The seed creates demo admin and customer accounts (listed in the README), and there's a public/sample-call.wav you can upload from /dashboard/upload. There's also a /transcription-demo page that switches between a sales call, a support call and a voicemail/no-answer example.
Two checks I run before shipping changes:
npx prisma validate
npx tsc --noEmit --pretty false --incremental false
What I took away
- Make AI calls explicit. A button the user presses is easier to reason about, cache and bill than a hidden call on render.
- Design for the missing key. Fallbacks keep demos and dev environments useful.
- Model the non-conversations. Voicemail and no-answer calls deserve their own category, or your scores lie.
- Keep a human verification step, and show how much has been verified.
- Fail safely on payments and auth. Disabled buttons with clear messages beat cryptic errors.
If you run QA for a call team, I'm curious: what share of your calls actually gets reviewed today?
Open to freelance & remote work
Portfolio: https://mafzalkalwardev.github.io/#hire-me
GitHub: https://github.com/mafzalkalwardev
WhatsApp: +92 307 9670503 — https://wa.me/923079670503
Top comments (0)