I built CompParity and PrimeTrade.ai for selection-related assignments. CompParity was for an AI Software Engineer internship selection round, while PrimeTrade.ai was built for a shortlisting assignment.
CompParity is my compensation-data app. I built it with Next.js and TypeScript, Prisma, and PostgreSQL on Neon, and deployed it on Vercel. Its level-normalized compensation APIs return P25/P50/P75 statistics.
I use prisma/schema.prisma as the source of truth for CompParity’s data model, which has six models and one enum. Company stores canonical hiring companies with unique names; CompanyAlias resolves alternate spellings to a company; LevelStandard defines the shared ladder with a code, human-readable title, and unique order; CompanyLevel pins a company-specific level name to one standard level; Role represents a normalized job family; and CompensationEntry stores a submitted data point. The EntryStatus enum has PENDING, VERIFIED, and REJECTED values.
I trim and lowercase company input before storage, then resolve it through CompanyAlias. Inputs such as " Google ", "google llc", and "GOOGLE" resolve to the same Company; aliases such as "amzn" and "aws" can resolve to Amazon. This avoids splitting aggregates across spelling variants and producing incorrect percentiles. I compute totalCompensation server-side as baseSalary + bonus + stock on every write. The request schema has no totalCompensation field, so a client-supplied total is silently dropped before business logic runs.
I prevent duplicate submissions with a SHA-256 submittedHash enforced by a unique database column, rather than relying on an application-level check followed by an insert. Its eight components are companyId, companyLevelId, roleId, location, base, bonus, stock, and currency. Because the hash uses resolved IDs rather than raw strings, equivalent submissions such as "amzn" and "Amazon" with identical compensation details hash identically; the database constraint also closes the race where identical requests arrive simultaneously.
I lock a company title’s mapping to the shared ladder on first use: the first submission of "Amazon SDE II" determines its standard rung using the caller’s levelCode, and later submissions reuse that stored mapping regardless of their submitted levelCode. This prevents a later L3-to-L5 reclassification from distorting existing percentiles. I made LevelStandard.order unique because two levels sharing a sort rank would make cross-level ranking ambiguous, and added eight query indexes across CompensationEntry, CompanyAlias, and CompanyLevel because filter, stats, and compare queries hit those columns. Company relations currently use onDelete: Cascade because there is no delete endpoint; if I add one, I need to revisit Restrict or a soft-delete flag to avoid cascading deletion of real user-submitted entries. Stats and compare responses are currency-bucketed, but the details are not captured here.
I designed CompParity around the idea that job titles are a weak basis for comparing compensation: the same title can describe different work at different companies. Instead, I map each company’s titles onto a shared LevelStandard ladder, so comparisons use seniority level rather than title.
| Shared level | Seniority | Amazon | Flipkart | |
|---|---|---|---|---|
| L2 | Junior | SDE I | L3 SWE | SDE 1 |
| L3 | Mid | SDE II | L4 SWE | SDE 2 |
| L4 | Senior | SDE III | L5 SWE | SDE 3 |
| L5 | Staff | — | L6 Staff SWE | Staff Engineer |
The shared ladder has eight levels, L1 through L8, each with a strict order. For example, a “Senior engineers” query resolves to LevelStandard L4 and groups Amazon SDE III, Google L5 SWE, and Flipkart SDE 3 together. A title-based system would need manually curated equivalence tables at query time to make that comparison.
- I built the APIs to accept compensation submissions, validate and normalize them, and reject duplicates; map company-specific titles onto the shared ladder; return P25, P50, and P75 percentiles for company, role, level, and location filters; and compare 2–3 companies side by side at an equivalent level.
I document five CompParity API endpoints for compensation ingestion, filtered listing, percentile statistics, company-level aggregation, and side-by-side comparison. API errors use { "error": string, "details"?: object }; the documented status codes are 200 for success, 201 for creation, 400 for validation, 404 for not found, and 409 for duplicates.
For POST /api/compensation, I require company, companyLevelName, levelCode, role, location, and baseSalary. If omitted, bonus and stock default to 0, and currency defaults to INR. The first submission for a company-and-title pair establishes its level mapping; later levelCode values are ignored in favor of the stored mapping. A successful 201 response includes the created entry, server-computed totalCompensation, and resolved company, role, and level names. Validation failures return 400 with field-level details, while an identical submission returns 409.
GET /api/compensation filters by company, role, standard level (L1–L8), location, and status (PENDING, VERIFIED, REJECTED, or ALL); status defaults to VERIFIED. Pagination defaults to page 1 and page size 20, with a maximum page size of 100. The response contains page, pageSize, total, totalPages, and results. A filter for a nonexistent company is a valid empty result: the endpoint returns 200 with an empty results array.
GET /api/compensation/stats accepts the listing filters except pagination and returns { filters, count, buckets }. Each currency-specific bucket contains currency, count, min, max, average, p25, p50, and p75; mixed currencies stay separate rather than being combined into a misleading average. GET /api/companies/:id returns company details and a junior-to-senior levels array, with each level's company-specific name, resolved standard level, entry count, and per-currency buckets; an unknown ID returns 404. GET /api/compare requires a level and 2–3 comma-separated company names, with optional role, location, and status filters. It returns one result per company; an unknown company is represented with found: false, count 0, and empty buckets without failing the comparison. Fewer than 2 or more than 3 companies returns 400.
I handle CompParity’s reliability edge cases explicitly: company names with different spelling or casing resolve through aliases, and an unseen company is created with an alias. Missing bonus or stock defaults to 0; any client-supplied totalCompensation is ignored and recomputed server-side. Exact duplicate resubmissions return 409, including concurrent races. Negative or missing required fields return 400 with per-field detail. Unknown filters produce an empty result, unknown IDs return 404, and neither becomes a 500. In a comparison, an unknown company appears with found: false while other results still return; requests for 1 or 4+ companies return 400.
I found a currency-mixing bug only after querying real data for Indian offices in INR and Seattle in USD without a location filter: an early stats endpoint returned a P50 blended across currencies, which was not meaningful. I fixed stats and comparison responses to bucket results by currency rather than average rupees and dollars together. I also moved duplicate prevention onto the database’s unique constraint and catch Prisma P2002 on insert, because a check-then-insert lookup has a race window under concurrent requests. I cache one Prisma client on globalThis for dev hot reload and serverless concurrency, using pooled connections for application traffic and a direct connection for migrations.
CompParity uses Next.js 16, where dynamic route params are asynchronous. For GET /api/companies/[id], I type params as Promise<{ id: string }> and await it; TypeScript catches a missed await at build time. Company aggregation currently runs one query per level rather than a single groupBy. I consider that an acceptable simplification for a handful of levels per company, but it would not hold up with dozens.
I develop CompParity with Node.js 20+ and a Neon Postgres project. Prisma CLI auto-loads .env, not .env.local, so my database scripts wrap Prisma with dotenv -e .env.local --. I use DATABASE_URL for the pooled connection (the host contains -pooler) and DATABASE_URL_UNPOOLED for the direct connection. The migration command runs prisma migrate dev; deploy replays committed migrations with prisma migrate deploy; the seed script is idempotent; and Prisma Studio runs at localhost:5555. TESTING.md contains a manual endpoint test plan with curl commands and expected success and failure responses.
npm install
npm run db:migrate
npm run db:seed
npm run dev # http://localhost:3000
I deployed CompParity to Vercel at https://comp-parity-main.vercel.app, using Neon’s production branch. The build command is prisma generate && next build. I keep DATABASE_URL and DATABASE_URL_UNPOOLED as encrypted Production and Preview environment variables, never commit them, and keep .env.local git-ignored. I verified against the live database after deployment that stats and comparison return real aggregated data, not seed placeholders.
I position CompParity around level-normalized cross-company compensation comparison, with total-compensation breakdowns, company and level aggregation, and P25/P50/P75 percentiles. I chose percentiles over a single average because compensation is right-skewed: a handful of very high offers can pull up the mean, while P25/P50/P75 better describe what to expect. Company reviews and culture, interview preparation, offer-letter analysis, and job-search integration are outside this phase.
- I left these items out of the current phase, in rough priority order: suppressing statistics for low sample sizes; an admin correction path for a wrong level mapping; changing money fields from
FloattoDecimal; authentication, rate limiting, and real submission verification; live FX conversion; and richer frontend visual design. I intentionally kept the visual design bare to focus on the backend. - I chose Next.js API routes over NestJS because five endpoints at MVP scale do not justify another deployable or the extra boilerplate.
- I validate request shapes with Zod at the API boundary so malformed requests receive readable field-level errors before database access.
- I deduplicate with a database-enforced
@uniquehash and catch insert conflicts, rather than doing afindFirstfollowed by an insert that leaves a race window. The hash uses resolved IDs, so aliases such asamznandAmazonwith otherwise identical details collide. - I keep the first company-title level mapping to avoid silently reclassifying existing entries and corrupting comparisons. If that initial mapping is wrong, correcting it needs an admin path; there is no public self-service correction.
- I compute percentiles in application code while filters have low hundreds of rows, but at real scale I should move this into one SQL aggregate so only summary values cross the network.
- I use
Floatfor MVP money fields despite its rounding imprecision; production money handling should move toDecimal. - Currency conversion is out of scope without an FX-rate feed, so statistics and comparisons remain bucketed by currency.
PrimeTrade.ai is my Binance Futures Testnet CLI. It supports market, limit, and stop-limit orders, and uses Pydantic validation, layered services, and pytest tests.
My PrimeTrade.ai README says the CLI was tested against testnet endpoints. Its order types are MARKET, LIMIT, and STOP-LIMIT, with Pydantic validation, exchange-rule checks, layered architecture, and structured logging.
I keep all my code at github.com/yaadhuu.