Recrot
A full-stack job management platform connecting tutors, parents, and companies with a role-based, subscription-gated system.
Two-person development team. I was one of the two developers and the one who reviewed and merged the feature branches into dev/main (26 of the 297 commits are my merge commits). The subsystems I built end to end: email verification and email-based 2FA (lib/utils/auth/email-2fa.ts, app/api/auth/email-2fa), the whole notification pipeline (in-app documents, the Nodemailer email route, FCM push, and the admin broadcast panel), the tutor payment and subscription-expiry system, tutor search with filtering, job soft-delete/restore, the parent-side hired-tutor and rating screens, and the security-audit remediation pass that added auth guards and closed the silent-failure paths in notifications. I also wrote the platform documentation — SRS, Docs/api-docs.md, and the 50-table PostgreSQL target schema (Docs/schema.md) for the current migration off Firebase. My co-developer owned the landing pages, most of the employer/admin dashboards, and the job/application UI.
// the problem
What was broken
Tutoring in Pakistan is matched through WhatsApp groups, Facebook posts, and word of mouth. A parent looking for an O-Level physics tutor in their own neighbourhood has no way to see who is qualified, who is nearby, or who anyone else has actually hired and rated. Tutors have the mirror problem: they hear about openings only if they happen to be in the right group at the right time, and they have no verifiable public record of past work. Tutoring agencies sat in the middle taking a cut for doing exactly this manual matching, badly and slowly.
// constraints
What made it hard
No card payment gateway was available for the business, so every subscription payment had to run through bank transfer plus a screenshot that a human admin verifies — the entire billing state machine had to be built around a manual approval step. The budget assumed Firebase's free and low tiers, which meant per-document-read pricing was a hard design constraint, not an optimisation to do later. The whole platform had to serve four distinct roles (tutor, parent, company, admin) with different dashboards and different permissions, and it had to be ready for a Flutter mobile app to consume the same backend later, which pushed us toward putting the rules somewhere both clients could reach. Two developers, no QA, no test suite.
// architecture
How it fits together
Next.js 15 App Router with Firebase as the entire backend: Auth for identity, Firestore for data, Cloud Storage for documents and payment screenshots, FCM for push. The consequential decision — and the one I would revisit — was to let client components talk to Firestore directly through the web SDK, with API routes reserved only for things that genuinely need a secret (Nodemailer credentials, the Firebase Admin SDK, admin user creation). That is why there are 76 page routes but only 15 API route handlers. It made the app fast to build and gave us free realtime updates via onSnapshot, but it moved the real authorization boundary into firestore.rules, which is a language that can express "only the tutor named on this application may update it" and cannot express "only if this tutor has applications left this month."
So enforcement ended up layered, deliberately. middleware.ts is a cheap first pass — it checks for the firebase-auth-token cookie and redirects, nothing more; the comment in it says as much, full JWT verification happens inside each handler via getVerifiedToken. firestore.rules is the backstop that a hostile client cannot get past: field-level diffs (request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status']) on hire requests), role lookups, and a default deny on everything unmatched. The business rules that rules can't express — subscription validity, application limits — live in shared helpers (verifySubscriptionServer, canPerformAction) that both the client-side applyToJob and the /api/tutor/apply-job route call. Rate limiting is a separate concern again: lib/utils/rateLimit.ts returns an Upstash Redis sliding-window limiter when the env vars are set and silently falls back to an in-memory Map for local dev, because an in-memory counter on Vercel's serverless functions limits nothing.
Client
Next.js App Router page (app/tutor/jobs/[slug]) calling Firestore via the web SDK
middleware.ts
Cookie presence check, role headers, redirect; blocks /api/admin and /api/tutor without a token
applyToJob
lib/utils/helpers/firebaseHelpers/application.ts — requireRole, duplicate-application check, job-is-active check
verifySubscriptionServer
Reads subscriptions/{uid}, compares ISO endDate, then canPerformAction against applicationLimit
Firestore
Writes the application doc, increments jobs.stats.totalApplications and subscriptions.applicationsUsed
firestore.rules
Independently re-checks the write — tutor role, own tutorId, status must be 'pending'
createNotification
Writes notifications/{id}, then fans out to email and push with the caller's ID token attached
/api/notifications/send-email + push-notification
Nodemailer and firebase-admin messaging — the only places server secrets live
// challenges
Where I got stuck
Every jobs page load read the entire jobs collection
getJobs fetched all documents ordered by createdAt and applied category, schooling-system, and status filters in JavaScript. The code comment justified it: doing it server-side would have required composite indexes. Firestore bills per document read, so the cost scaled with collection size times traffic, and the public landing page called searchTutors twice per anonymous visitor — a bot hitting / cost us money with no login. The fix was to push every filter into where clauses, add limit(20) with startAfter cursor pagination, replace count-by-reading-everything with getCountFromServer/getAggregateFromServer, and ship the ten composite indexes in firestore.indexes.json that this made necessary. Documented projection in Docs/COST_ESTIMATION.md: ~4.2M reads/day down to ~200k/day at a 1,000-DAU baseline.
Plan values were stored with inconsistent casing, which blocked the query we needed
Every job posting notified matching tutors, and finding them meant reading the whole subscriptions collection and filtering for paid plans in JavaScript — because plan was written as "Basic"/"Pro" in some paths and lowercase in others, and Firestore's in operator is case-sensitive. This is quadratic: subscriptions × jobs posted. I could not simply normalise the field without a data migration and touching every write path, so the interim fix was where("plan", "in", ["Basic", "Pro"]) matching the actual stored casing, plus where("status", "==", "active"). That second clause fixed a real bug as a side effect — expired-but-not-yet-deactivated tutors had been receiving job-match emails they could not act on. The proper normalisation is in the Postgres schema as a strict subscription_plan enum.
Billing with no payment gateway
Tutors upload a bank-transfer screenshot, an admin approves or rejects it, and only then does verifyPayment write the subscription with an applicationLimit pulled from paymentSettings. Upgrades and downgrades have to cancel the old plan before activating the new one, and a tutor can have a pending submission sitting against an already-active plan, so isPending had to become a first-class state in SubscriptionData separate from isActive — several call sites broke when I added it because they were treating "not active" as "free". Expiry runs on three layers: a daily Vercel cron (0 0 * * *) that flips expired rows to inactive, an onSnapshot listener that recomputes on the client, and verifySubscriptionServer at the moment of the action, because the first two can both be stale or bypassed.
The session cookie did not prove anything
The production audit found that /api/set-session stored the literal string "true" in firebase-auth-token rather than the ID token, which meant middleware could confirm *somebody* was logged in and nothing else — and /api/notifications/[userId] had no auth at all, so any caller could read any user's notifications by changing the path segment. Fixing it meant introducing getVerifiedToken, which verifies the bearer ID token through the Admin SDK, and then adding decoded.uid!== userId → 403 checks to every handler that takes a user ID. Twenty-three of the twenty-six issues in Docs/PRODUCTION_AUDIT.md are now marked resolved; three medium ones (the apply-job TODO, four `) are still open and tracked in that file.
Notification fan-out blocked job creation
A new job notifies every matching paid tutor across three channels — Firestore document, email, and FCM push — and the original loop awaited each tutor in turn, so posting a job with a few hundred matches meant a few hundred sequential round trips while the employer stared at a spinner. notifyTutorsInChunks now runs Promise.allSettled over chunks of 20. allSettled rather than all is the point: one tutor with a stale FCM token must not abort the fan-out for everyone behind them, and the same reasoning is why createNotification swallows email and push errors rather than failing the notification write.
// new ground
What I hadn't used before
getCountFromServer and getAggregateFromServer(sum(...)) were the difference between the admin dashboard reading roughly five thousand documents to display six numbers and reading about twenty index entries. I had been treating Firestore as "documents only" and reaching for a maintained counter field whenever I needed a total; knowing the aggregation API exists changes which denormalisations are worth the write amplification.Map in module scope. It works locally and does approximately nothing on Vercel, where each concurrent invocation gets its own instance — the audit flagged it. Upstash Redis with a sliding window gave shared state, and the fallback-to-in-memory shape in createRateLimiter keeps local dev working without a Redis instance. The general lesson stuck harder than the specific tool: on serverless, any in-process state is per-invocation until proven otherwise.fcmTokens.web and fcmTokens.app on the profile document and both are sent to. Getting the service worker, the VAPID key, and permission prompts right on web push — and understanding that a token is per-device-per-browser, not per-user — was new.Docs/schema.md meant reading every Firestore write path in lib/utils/helpers/firebaseHelpers/*.ts to work out what the implicit schema actually was, and that exercise surfaced problems no amount of reading the app would have: stat counters stored as strings, dates stored as ISO strings compared with <=, three dead collections (user_roles, employees, messages) with no live reads, and one status field meaning two different things depending on the collection.// takeaways
What I took away
- Firestore security rules define what a client can write, not what your product means — anything with a quantity in it ("three applications a month") cannot live there, and pretending otherwise leaves the rule enforced only in code the user controls.
- "Avoid requiring composite indexes" is not a performance decision, it is a deferred bill; the index file is the cheap artifact and the full-collection scan is the expensive one.
- A manual payment approval step is not a smaller feature than a payment gateway — it adds a
pendingstate that every screen has to render, and the states you forget are the ones support tickets are made of. - Writing the target Postgres schema was the most effective audit of the Firebase app we ever ran, because a relational model refuses to compile the ambiguities a document store happily stores.
Promise.allSettledoverPromise.allfor fan-out is not a style preference; with a third-party delivery channel in the loop, one bad token will otherwise silently cut off everyone after it.
// the journey
How it actually went
I came onto this as the second developer with the auth and notification surface as my half. That part started well — email verification, then 2FA with backup codes, then the notification system growing from in-app documents to email to FCM push over about a month of commits in November. Building each channel behind one createNotification call was the decision that held up best; by the time we added hire requests and subscription updates months later, wiring a new notification type into three channels was a single function call with a different type string.
What I got wrong early was trusting the client. We called Firestore directly from React components everywhere because it was fast and gave us realtime for free, and I told myself the security rules were the boundary. They were, for row-level access. They were not for anything with business logic in it, and the seam shows in the codebase today: applyToJob in the client helper does the subscription check, the application-limit check, and the duplicate check, and /api/tutor/apply-job does the same three checks with a TODO where the actual write should be. Two enforcement points, one of them incomplete, both of them mine. That is exactly the kind of thing a client can be persuaded to skip.
The moment it clicked was the June production audit. Seeing twenty-six findings on one page — a service account private key committed to the repo, notification endpoints anyone could read by changing a URL segment, a session cookie containing the string "true" — reframed the work from "ship features" to "the auth boundary is a thing that has to actually exist somewhere." I spent most of June and July on guards, getVerifiedToken, CSP headers, and rate limits rather than features, and the July cost work came out of the same shift: once I started reading our own query code adversarially, getJobs reading two thousand documents per page load was obvious and had been sitting there since November.
What I would do differently is the shape, not the stack. Firebase Auth and FCM earned their place. Firestore-from-the-client did not — the reads it costs and the rules gymnastics it forces would both have been avoided by putting a thin server layer in front of the data from day one. That is essentially what the migration now under way does: Postgres on NeonDB behind a FastAPI service, with hire cascades, subscription expiry, and matching living in service-layer transactions instead of scattered across client helpers. Writing that schema, I got to name every ambiguity we had shipped, which is a strange and useful way to review ten months of your own work.
// outcome
What it changed
Shipped and running on Vercel. From the repository: 297 commits between 3 Oct 2025 and 23 Jul 2026, 76 page routes across four role dashboards plus public marketing and search, 15 API route handlers, and Firestore rules covering 16 collections with a default deny. Feature-complete for tutor, parent, company, and admin flows — job posting with soft delete, applications with shortlist/demo/hire/withdraw, employer-initiated hire requests gated on the tutor's plan, manual-payment subscriptions with monthly and yearly cycles, ratings, and admin moderation.
The production audit (Docs/PRODUCTION_AUDIT.md) lists 26 findings; 23 are marked resolved, 3 remain open at medium severity. The cost pass moved every hot query to server-side filters with pagination and count aggregations, cut Sentry trace sampling from 100% to 10%, and reduced the notification listener from 50 to 20 documents. Docs/COST_ESTIMATION.md projects that work as ~$125–205/month down to ~$40–60/month at a 1,000-DAU baseline — a documented internal estimate against Firebase list pricing, not a measurement.
Currently mid-migration: a 50-table PostgreSQL schema and a full REST API specification are written and committed, targeting NeonDB behind FastAPI, with 49 Dart entity models prepared for the Flutter client. The mobile app carries every role except admin, which stays web-only.
// overview
In more detail
Recrot is a full-stack job management platform connecting tutors, parents, and companies. Tutors can browse and apply to tutoring jobs, while parents and companies can post jobs and manage applications — all within a role-based, subscription-gated system.
The platform features four user roles (Tutor, Parent, Company, Admin) with email-based 2FA, a complete job management lifecycle, a three-tier subscription system, a comprehensive admin dashboard, and Firebase-powered real-time notifications.
// features
What it does
- Multi-Role Authentication — Four roles (Tutor, Parent, Company, Admin) with email 2FA and backup codes
- Job Management — Post, edit, and manage tutoring jobs with rich details
- Application Lifecycle — Submitted → Shortlisted → Hired / Rejected
- Subscription System — Three-tier plans (Free, Basic, Pro) with real-time enforcement
- Admin Dashboard — Full control over users, jobs, applications, subscriptions, and payments
- Push Notifications via FCM with per-device token management
- Email notifications for key events (password reset, application updates)
- Comprehensive Firestore security rules with document-level RBAC
- 101 reusable components organized by feature domain
// stack