Backend Internship practical challenge submission. A NestJS + Prisma API for managing internship applications, with JWT-authenticated admin access.
- NestJS (TypeScript) — modular controller/service/DTO architecture
- Prisma ORM against MariaDB/MySQL (also works against PostgreSQL or SQLite with a one-line provider change)
- JWT bearer auth via
@nestjs/jwt+passport-jwt, passwords hashed withbcrypt - class-validator / class-transformer for request validation
- Swagger/OpenAPI via
@nestjs/swagger - Jest for unit and e2e tests
npm install
cp .env.example .envThen edit .env:
- Set
DATABASE_URLto your MariaDB/MySQL connection string:DATABASE_URL="mysql://<user>:<password>@localhost:3306/<database>" - Set
JWT_SECRETto a real random string (e.g.openssl rand -hex 32).
Create the database and a dedicated user in MariaDB before migrating, e.g.:
CREATE DATABASE infnova_applicants;
CREATE USER 'infnova'@'localhost' IDENTIFIED BY 'your-password';
GRANT ALL PRIVILEGES ON infnova_applicants.* TO 'infnova'@'localhost';Note:
prisma migrate devcreates a temporary shadow database to validate migrations safely, which requires the DB user to be able to create/drop databases — not just query the one it owns. For local development this project's user was granted broader privileges (GRANT ALL PRIVILEGES ON *.* ... WITH GRANT OPTION) to support this. A production deployment would instead useprisma migrate deploy(which doesn't need a shadow database) with a narrowly scoped user.
Then run:
npx prisma migrate dev --name init # creates the tables
npm run prisma:seed # creates an admin user + sample applicants
npm run start:devThe API runs at http://localhost:3000. Swagger docs are at
http://localhost:3000/api/docs.
Seeded admin login (from .env):
- email:
SEED_ADMIN_EMAIL(defaultadmin@infnova.test) - password:
SEED_ADMIN_PASSWORD(defaultChangeMe123!)
Change provider in prisma/schema.prisma (mysql, postgresql, or
sqlite), point DATABASE_URL at the corresponding instance, then re-run
npx prisma migrate dev.
POST /api/auth/loginwith{ email, password }→ returnsaccessToken.- Send
Authorization: Bearer <accessToken>on every other request. All/api/applicants/*and/api/dashboard/*routes are guarded. GET /api/auth/mereturns the authenticated admin's profile.
There is no self-service admin registration endpoint by design — admins are provisioned via the seed script (or directly in the DB), since the brief only asked for admin login, not admin sign-up.
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/login |
Admin login, returns JWT |
| GET | /api/auth/me |
Current admin profile |
| POST | /api/applicants |
Create applicant |
| GET | /api/applicants |
Paginated list — page, limit, search, status, track, sortBy, sortOrder |
| GET | /api/applicants/:id |
Get one applicant |
| PATCH | /api/applicants/:id |
Update applicant details |
| PATCH | /api/applicants/:id/status |
Update status (enforces business rule) |
| PATCH | /api/applicants/:id/notes |
Replace internal notes (≤1000 chars) |
| DELETE | /api/applicants/:id |
Soft-delete |
| GET | /api/dashboard/summary |
Totals by status and track (excludes deleted) |
src/prisma—PrismaService, exposed as a global module so it's injectable anywhere without re-importing.src/auth— login, JWT strategy/guard,@CurrentUser()decorator.src/applicants— controller (routing only), service (all business logic), DTOs (one per operation, so validation rules are scoped to exactly what that request needs).src/dashboard— read-only summary stats, uses PrismagroupBy.src/common— the global exception filter and the shared pagination DTO.
Controllers stay thin (auth guard + DTO binding only); all business rules (status-transition check, soft-delete filtering, uniqueness) live in the services, as requested in the brief.
- Unique email — enforced at the DB level (
@uniquein the Prisma schema) and surfaced as a409 Conflictby the global exception filter. - Notes ≤ 1000 chars —
@MaxLength(1000)inUpdateNotesDto. - Rejected → Accepted blocked — a single table of blocked transitions in
ApplicantsService.updateStatus. All other transitions, including re-opening a rejected applicant back to Pending/Shortlisted, are allowed. - Soft delete —
deletedAttimestamp instead of a row delete. Every read path (findAll,findOne, and therefore every dashboard count) filtersdeletedAt: null. - Auth required for writes/reads of applicant data —
JwtAuthGuardon the wholeApplicantsControllerandDashboardController.
npm run test # unit tests (auth + applicants business logic)
npm run test:e2e # e2e: validation + auth-guard smoke tests
npm run test:cov # coverage reportUnit tests focus on the two things most likely to be graded closely: the Rejected→Accepted block (including that every other transition is still allowed) and login success/failure paths. E2E tests confirm the global validation pipe and JWT guard are actually wired up on the running app.
All endpoints were also manually verified through Swagger UI (/api/docs),
including the auth flow, full applicant CRUD, the blocked status transition,
and soft-delete exclusion from both the list and dashboard summary.
- Only one role exists (
Admin) — there's no separate "viewer" role, since the brief only mentions administrators. - Because email uniqueness is enforced at the DB level and soft-deleted rows
aren't removed, a soft-deleted applicant's email can't currently be reused
for a new applicant. A follow-up would be a partial unique index scoped to
deletedAt IS NULL. Flagging this as a known limitation rather than solving it here. PATCH /api/applicants/:idintentionally excludesstatus— status only changes through the dedicated/statusendpoint so the transition rule can't be bypassed via the general update route.- No refresh tokens / token revocation — a single JWT with an expiry
(
JWT_EXPIRES_IN) was sufficient for the scope of this challenge. - Search on name/email uses a
containsmatch. MariaDB's default collation (utf8mb4_general_ci) makes this case-insensitive out of the box; no extra configuration was needed for that here. - The local dev database user has broad privileges (see the shadow-database
note above) purely to support
prisma migrate dev. A production setup would scope this down and useprisma migrate deployinstead.
See .env.example. JWT_SECRET and SEED_ADMIN_PASSWORD in that file are
placeholders only — replace them locally; nothing real is committed.