Service Spec: core-api

What this service is

The main business logic service. Starts as a modular monolith — all modules share one Fastify process and one Prisma client. Module boundaries are enforced by folder structure so individual modules can be extracted to separate services later without rewriting.

All incoming requests already have x-tenant-id, x-user-id, x-user-role, x-user-permissions headers injected by api-gateway. core-api trusts these (internal network only) and does not re-validate JWTs.


Tech stack

  • Runtime: Node 20
  • Framework: Fastify v4
  • Language: TypeScript (strict mode)
  • ORM: Prisma 5 (with Postgres 16)
  • Redis client: ioredis
  • Queue: bullmq
  • Validation: zod + fastify-zod for route schemas
  • File uploads: @fastify/multipart
  • API docs: @fastify/swagger + @fastify/swagger-ui (auto-generated from Zod schemas)
  • Auth: arctic (OAuth providers), otplib (TOTP/OTP)
  • Email OTP: Send via notification queue, not directly

Folder structure

apps/core-api/
├── src/
│   ├── main.ts
│   ├── server.ts
│   ├── plugins/
│   │   ├── prisma.ts           # Prisma client singleton + tenant middleware
│   │   ├── redis.ts
│   │   ├── queue.ts            # BullMQ queue instances
│   │   ├── s3.ts               # AWS S3 client
│   │   └── swagger.ts
│   ├── middleware/
│   │   ├── extract-context.ts  # parse x-tenant-id/user headers → req.tenant, req.user
│   │   ├── require-permission.ts
│   │   └── require-role.ts
│   ├── modules/
│   │   ├── auth/
│   │   ├── tenancy/
│   │   ├── rbac/
│   │   ├── students/
│   │   ├── placement-cycles/
│   │   ├── jobs/
│   │   ├── applications/
│   │   ├── resumes/
│   │   ├── segmentation/
│   │   ├── events/
│   │   ├── student-intel/
│   │   ├── companies/
│   │   └── notifications/
│   ├── shared/
│   │   ├── list-query.ts       # universal pagination/sort/filter builder
│   │   ├── errors.ts           # typed error classes
│   │   ├── s3.ts               # upload/sign helpers
│   │   └── pagination.ts       # response shape builder
│   └── types/
│       └── fastify.d.ts
├── prisma/
│   ├── schema.prisma
│   └── migrations/
├── Dockerfile
├── tsconfig.json
└── package.json

Global Prisma tenant middleware

This is the core of multitenancy. Inject once, works everywhere.

// plugins/prisma.ts
prisma.$use(async (params, next) => {
  const tenantId = AsyncLocalStorage.getStore()?.tenantId;
  if (!tenantId) throw new Error('No tenant context');
 
  // Inject tenant_id on create
  if (params.action === 'create') {
    params.args.data.tenant_id = tenantId;
  }
  if (params.action === 'createMany') {
    params.args.data = params.args.data.map((d: any) => ({ ...d, tenant_id: tenantId }));
  }
 
  // Scope all reads and writes
  const scopedActions = ['findFirst','findMany','findUnique','update','updateMany','delete','deleteMany','count','aggregate'];
  if (scopedActions.includes(params.action)) {
    params.args.where = { ...params.args.where, tenant_id: tenantId };
  }
 
  return next(params);
});

Set tenant context at request start:

// middleware/extract-context.ts
app.addHook('preHandler', async (req) => {
  const tenantId = req.headers['x-tenant-uuid'] as string;
  const userId = req.headers['x-user-id'] as string;
  const role = req.headers['x-user-role'] as UserRole;
  const permissions = (req.headers['x-user-permissions'] as string ?? '').split(',');
  
  req.tenant = { id: tenantId };
  req.user = { id: userId, role, permissions };
  
  // Set AsyncLocalStorage for Prisma middleware
  tenantStorage.run({ tenantId }, async () => { /* request handled inside this context */ });
});

Universal list query builder

Every list endpoint uses this. Keeps contracts consistent across the whole API.

// shared/list-query.ts
 
interface ListParams {
  page?: number;       // default 1
  limit?: number;      // default 20, max 100
  sort?: string;       // "field:asc" or "field:desc"
  q?: string;          // full-text search
  filter?: Record<string, string | string[]>;
}
 
interface ListResponse<T> {
  data: T[];
  meta: {
    total: number;
    page: number;
    limit: number;
    pages: number;
  };
}
 
function buildListQuery(params: ListParams, allowedSortFields: string[], allowedFilterFields: string[]) {
  // validates sort field against allowlist
  // builds Prisma where clause from filter params
  // builds Prisma orderBy from sort param
  // builds skip/take from page/limit
  // returns { where, orderBy, skip, take }
}

Usage in a route:

// GET /api/jobs?page=2&limit=20&sort=deadline:asc&filter[status]=open&q=mckinsey
const { where, orderBy, skip, take } = buildListQuery(req.query, ['deadline','title','created_at'], ['status','sector']);
const [data, total] = await Promise.all([
  prisma.job.findMany({ where, orderBy, skip, take }),
  prisma.job.count({ where }),
]);
return buildPaginationResponse(data, total, params);

Module: auth

Routes

POST /api/auth/google/url              → returns Google OAuth URL
GET  /api/auth/google/callback         → handle OAuth callback
POST /api/auth/otp/request             → send OTP to email
POST /api/auth/otp/verify              → verify OTP → issue tokens
POST /api/auth/refresh                 → exchange refresh token → new access token
POST /api/auth/logout                  → revoke refresh token
GET  /api/auth/me                      → return current user from JWT

Token issuance

function issueTokens(user: User, tenant: Tenant) {
  const accessToken = jwt.sign(
    {
      sub: user.id,
      tenant_id: tenant.id,
      role: user.role,
      permissions: getPermissionsForRole(user.role),
    },
    tenant.jwtSecret,
    { expiresIn: '15m' }
  );
 
  const refreshToken = crypto.randomUUID();
  // Store: SET refresh:{refreshToken} {userId}:{tenantId} EX 2592000 (30 days)
  redis.setex(`refresh:${refreshToken}`, 2592000, `${user.id}:${tenant.id}`);
 
  return { accessToken, refreshToken };
}

Domain rule check (called on both OAuth and OTP flows)

async function checkDomainRules(email: string, tenant: TenantConfig): Promise<UserRole | null> {
  const domain = email.split('@')[1];
  
  if (!tenant.domainRules.allowedDomains.includes(domain)) return null;
  
  for (const matcher of tenant.domainRules.matchers) {
    if (matcher.contains && email.includes(matcher.contains)) return matcher.role;
    if (matcher.endsWith && email.endsWith(matcher.endsWith)) return matcher.role;
  }
  
  if (tenant.domainRules.allowAnyFromDomain) return 'student';
  
  // Check manual CSV allowlist
  const allowlisted = await checkCsvAllowlist(email, tenant);
  if (allowlisted) return allowlisted.role;
  
  return null;
}

OTP flow

  1. POST /auth/otp/request — generate 6-digit OTP, store SET otp:{email}:{tenantId} {code} EX 600 (10 min TTL), enqueue email job
  2. POST /auth/otp/verify — fetch from Redis, compare, delete key on success, run domain rule check, find or create user, issue tokens

Module: tenancy

Routes

GET    /api/internal/tenants/:slug/config     → (internal only) full config for gateway cache
GET    /api/tenants/:slug                     → public tenant info (name, logo)
PUT    /api/tenants/:slug/config              → update config (super_admin only)
PUT    /api/tenants/:slug/domain-rules        → update domain rules
POST   /api/tenants/:slug/allowlist/upload    → upload CSV of allowed emails
GET    /api/tenants/:slug/allowlist           → list current allowlist entries
DELETE /api/tenants/:slug/allowlist/:id       → remove an entry
GET    /api/tenants/:slug/feature-flags       → list flags
PUT    /api/tenants/:slug/feature-flags       → update flags (super_admin only)

After any config update: publish to tenant:config:invalidate Redis channel so gateway clears its cache.


Module: rbac

Routes

GET    /api/tenants/:slug/roles               → list roles (built-in + custom)
POST   /api/tenants/:slug/roles               → create custom role (super_admin)
GET    /api/tenants/:slug/users/:id/role      → get user's role
PUT    /api/tenants/:slug/users/:id/role      → assign role (must be same or lower role than assigner)
GET    /api/tenants/:slug/api-keys            → list API keys (super_admin)
POST   /api/tenants/:slug/api-keys            → create API key
DELETE /api/tenants/:slug/api-keys/:id        → revoke API key

Permission check middleware

function requirePermission(permission: string) {
  return async (req: FastifyRequest) => {
    if (!req.user.permissions.includes(permission)) {
      throw new ForbiddenError(`Requires permission: ${permission}`);
    }
  };
}

Built-in role permissions

const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
  super_admin: ['*'],   // wildcard — all permissions
  admin_l1: [
    'students:read', 'students:create', 'students:update',
    'cycles:*', 'jobs:*', 'applications:read', 'applications:update',
    'verifiers:create', 'events:*', 'analytics:read', 'exports:*',
    'roles:create_below',    // can create admin_l2, verifier, student
  ],
  admin_l2: [
    'students:read', 'students:create',
    'cycles:read', 'jobs:*', 'applications:read', 'applications:update',
    'verifiers:create', 'events:read', 'events:create',
    'analytics:read', 'exports:*',
  ],
  verifier: [
    'students:read_assigned',    // only students mapped to them
    'verifications:*',
  ],
  student: [
    'profile:read_own', 'profile:update_own',
    'resumes:*_own',
    'applications:*_own',
    'jobs:read',
    'events:read',
  ],
};

Module: students

Routes

# /me routes (student self-service)
GET    /api/me/profile                        → own profile
PUT    /api/me/profile                        → update own profile
POST   /api/me/profile/documents              → upload proof document
GET    /api/me/profile/verification-status    → verification status per section
 
# Admin routes
GET    /api/tenants/:slug/students            → list (paginated, filterable, searchable)
POST   /api/tenants/:slug/students            → create student manually
GET    /api/tenants/:slug/students/:id        → full student view (includes external dossier)
PUT    /api/tenants/:slug/students/:id        → edit student profile
DELETE /api/tenants/:slug/students/:id        → delete
PUT    /api/tenants/:slug/students/:id/role   → change role
GET    /api/tenants/:slug/students/:id/dossier → fetch from external API (config.externalDossierApiUrl)
POST   /api/tenants/:slug/students/bulk       → bulk create from CSV
GET    /api/tenants/:slug/students/export     → CSV/Excel export (scoped by segmentation)

Student profile shape

interface StudentProfile {
  id: string;
  tenantId: string;
  userId: string;           // FK to users table
  
  // Academic
  name: string;
  email: string;
  rollNumber?: string;
  batchYear: number;        // e.g. 2025
  major: string;
  minor?: string;
  cgpa?: number;
  cgpaScale: number;        // 4.0 or 10.0
  category?: string;        // general, OBC, SC, ST etc
  
  // Custom columns (per-tenant labels)
  c1: boolean;  // e.g. "CPP attended"
  c2: boolean;  // e.g. "IPP attended"
  c3: boolean;
  c4: boolean;
  c5: boolean;
  
  // Status
  profileVerified: boolean;
  isActive: boolean;
  
  // Timestamps
  createdAt: Date;
  updatedAt: Date;
}

External dossier fetch

// GET /api/tenants/:slug/students/:id/dossier
async function fetchExternalDossier(studentId: string, tenant: TenantConfig) {
  if (!tenant.externalDossierApiUrl) return null;
  
  const student = await prisma.student.findUnique({ where: { id: studentId } });
  
  const response = await fetch(`${tenant.externalDossierApiUrl}?email=${student.email}`, {
    headers: { 'Authorization': `Bearer ${tenant.externalDossierApiKey}` }
  });
  
  return response.json();  // shape is tenant-specific, pass through as-is
}

Module: placement-cycles

Routes

GET    /api/tenants/:slug/cycles              → list cycles
POST   /api/tenants/:slug/cycles              → create cycle
GET    /api/tenants/:slug/cycles/:id          → get cycle detail
PUT    /api/tenants/:slug/cycles/:id          → update
DELETE /api/tenants/:slug/cycles/:id          → delete
PUT    /api/tenants/:slug/cycles/:id/enrollment → toggle enrollment on/off
POST   /api/tenants/:slug/cycles/:id/enroll-bulk → enroll students matching segmentation
POST   /api/tenants/:slug/cycles/:id/enroll-csv  → enroll from uploaded CSV
GET    /api/tenants/:slug/cycles/:id/students    → list enrolled students
GET    /api/me/cycles                            → student: list cycles they're eligible for
POST   /api/me/cycles/:id/enroll                 → student self-enroll (if enrollment open)

Cycle shape

interface PlacementCycle {
  id: string;
  tenantId: string;
  name: string;             // "Placements 2024-25"
  batchYear: number;
  type: 'placement' | 'internship';
  status: 'draft' | 'active' | 'closed';
  enrollmentOpen: boolean;
  eligibilitySegmentationId?: string;  // which students can enroll
  assignedAdminIds: string[];           // admin_l1/l2 mapped to this cycle
  config: {
    cat1Rule?: string;       // "no more cat1 if already placed"
    cat2MaxApplies?: number; // max applications for cat2
    noPptNoApply: boolean;   // if true, students must attend PPT to apply
    autoDebarDays: number;   // days of debarment after auto-debar trigger
  };
  createdAt: Date;
}

Module: jobs

Routes

GET    /api/tenants/:slug/cycles/:cycleId/jobs          → list jobs in cycle
POST   /api/tenants/:slug/cycles/:cycleId/jobs          → create job
GET    /api/tenants/:slug/cycles/:cycleId/jobs/:id      → get job detail
PUT    /api/tenants/:slug/cycles/:cycleId/jobs/:id      → update
DELETE /api/tenants/:slug/cycles/:cycleId/jobs/:id      → delete
PUT    /api/tenants/:slug/cycles/:cycleId/jobs/:id/toggle-applications → open/close applications
 
GET    /api/me/jobs                                      → student: list all visible jobs
GET    /api/me/jobs/:id                                  → student: job detail + eligibility check

Job shape

interface Job {
  id: string;
  tenantId: string;
  cycleId: string;
  companyId: string;
  
  title: string;
  description: string;
  location: string;
  sector: string;
  ctc?: number;           // in LPA
  ctcBreakdown?: string;
  jobType: 'fulltime' | 'internship' | 'ppo';
  
  eligibilitySegmentationId?: string;  // additional eligibility on top of cycle enrollment
  acceptingApplications: boolean;
  deadline?: Date;        // auto-closes when deadline passes (cron job)
  
  // Application requirements
  requireResume: boolean;
  requireCoverLetter: boolean;
  requireVideo: boolean;
  
  status: 'draft' | 'published' | 'closed';
  createdAt: Date;
}

Eligibility check (for student job view)

async function checkStudentJobEligibility(studentId: string, jobId: string): Promise<{
  eligible: boolean;
  reasons: string[];  // if not eligible, human-readable reasons
}> {
  // 1. Student must be enrolled in the job's cycle
  // 2. Student must match job's eligibilitySegmentation (if set)
  // 3. Cycle config rules: noPptNoApply, cat1/cat2 limits, debarment
  // 4. Job must be accepting applications + not past deadline
}

Module: applications

Routes

POST   /api/me/jobs/:jobId/apply                    → student: submit application
DELETE /api/me/applications/:id                      → student: withdraw (if allowed)
GET    /api/me/applications                          → student: list own applications (filterable)
 
GET    /api/tenants/:slug/cycles/:cycleId/applications       → admin: all applications in cycle
GET    /api/tenants/:slug/cycles/:cycleId/jobs/:jobId/applications → admin: per-job
PUT    /api/tenants/:slug/applications/:id/status   → admin: shortlist/reject/offer
POST   /api/tenants/:slug/cycles/:cycleId/applications/bulk-download → download as ZIP
POST   /api/tenants/:slug/applications/:id/send-to-recruiter → email application to recruiter

Application shape

interface Application {
  id: string;
  tenantId: string;
  jobId: string;
  studentId: string;
  resumeId: string;       // which resume version they applied with
  coverLetter?: string;
  videoUrl?: string;      // S3 URL
  
  status: 'submitted' | 'shortlisted' | 'rejected' | 'offered' | 'accepted' | 'withdrawn';
  statusHistory: Array<{ status: string; changedAt: Date; changedBy: string; note?: string }>;
  
  // Debarment triggers
  selfDebarred: boolean;
  autoDebarred: boolean;
  debarredUntil?: Date;
  
  submittedAt: Date;
}

Module: segmentation

This is the core query engine. Used by: cycle eligibility, job eligibility, student intel views, bulk actions, analytics.

Routes

POST   /api/tenants/:slug/segmentations              → create/save a segmentation
GET    /api/tenants/:slug/segmentations              → list saved segmentations
GET    /api/tenants/:slug/segmentations/:id          → get
PUT    /api/tenants/:slug/segmentations/:id          → update
DELETE /api/tenants/:slug/segmentations/:id          → delete
POST   /api/tenants/:slug/segmentations/evaluate     → evaluate a definition against students → returns count + list
POST   /api/tenants/:slug/segmentations/summary      → evaluate + aggregate (COUNT, MIN, MAX, AVG per column)

Segmentation definition schema

interface SegmentationDefinition {
  groups: SegmentationGroup[];
  groupOperator: 'AND' | 'OR';   // how groups are combined
  includeIndividuals: string[];   // student IDs always included
  excludeIndividuals: string[];   // student IDs always excluded
}
 
interface SegmentationGroup {
  operator: 'AND' | 'OR';       // how conditions within this group are combined
  conditions: SegmentationCondition[];
}
 
interface SegmentationCondition {
  field: string;                  // "batch_year", "major", "cgpa", "c1", "attended_ppt_events"
  operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'not_in' | 'contains' | 'not_contains' | 'is_null' | 'is_not_null';
  value: string | number | boolean | string[];
}

Segmentation SQL engine

function buildSegmentationQuery(def: SegmentationDefinition, tenantId: string): string {
  // Translates each condition to a SQL fragment
  // Combines conditions within a group with the group's operator
  // Combines groups with the top-level groupOperator
  // Always ANDs in: tenant_id = tenantId
  // Handles includeIndividuals / excludeIndividuals with UNION/EXCEPT
  
  // Example output for:
  // Group1 (AND): batch_year = 2025 AND major IN ('CS', 'Econ')
  // OR
  // Group2 (AND): cgpa > 8.0 AND c1 = true
  
  return `
    SELECT s.* FROM students s
    WHERE s.tenant_id = '${tenantId}'
    AND (
      (s.batch_year = 2025 AND s.major IN ('CS', 'Econ'))
      OR
      (s.cgpa > 8.0 AND s.c1 = true)
    )
    AND s.id NOT IN (${excludeIndividuals.map(id => `'${id}'`).join(',')})
    UNION
    SELECT s.* FROM students s WHERE s.id IN (${includeIndividuals.map(id => `'${id}'`).join(',')})
  `;
}

Supported fields for segmentation conditions:

  • Student fields: batch_year, major, minor, cgpa, category, c1c5, profile_verified, is_active, roll_number
  • Computed: applications_count, offers_count, attended_ppt_event_ids (array contains check), enrolled_cycle_ids

Module: resumes

See product/resume.md for full JSON schema. API spec:

GET    /api/me/resumes                        → list own resumes
POST   /api/me/resumes                        → create resume (from profile snapshot)
GET    /api/me/resumes/:id                    → get resume
PUT    /api/me/resumes/:id                    → update (triggers re-verification of changed sections)
DELETE /api/me/resumes/:id                    → delete
POST   /api/me/resumes/:id/verify-request     → request verification for pending sections
POST   /api/me/resumes/:id/ai-variant         → trigger AI rewrite (enqueues ai-worker job)
 
GET    /api/tenants/:slug/students/:studentId/resumes       → admin: view student resumes
PUT    /api/tenants/:slug/verifications/:id/approve         → verifier: approve a verification request
PUT    /api/tenants/:slug/verifications/:id/reject          → verifier: reject with comment
GET    /api/tenants/:slug/verifications                     → verifier: list pending verifications (own assignments)

Module: events

GET    /api/tenants/:slug/events              → list events
POST   /api/tenants/:slug/events              → create event (PPT, test, etc.)
GET    /api/tenants/:slug/events/:id          → event detail
PUT    /api/tenants/:slug/events/:id          → update
DELETE /api/tenants/:slug/events/:id          → delete
GET    /api/tenants/:slug/events/:id/qr       → generate QR code for attendance
POST   /api/tenants/:slug/events/:id/attend   → mark attendance manually (admin)
POST   /api/tenants/:slug/events/:id/attend/qr → verify QR scan (from student app)
GET    /api/tenants/:slug/events/:id/attendance → list attendees
 
GET    /api/me/events                         → student: upcoming events
POST   /api/me/events/:id/attend/qr           → student: scan QR to self-check-in
 
# Exception requests
POST   /api/me/events/:id/exception-request   → student: request PPT exception
GET    /api/tenants/:slug/events/:id/exceptions → admin: list exception requests
PUT    /api/tenants/:slug/events/:eventId/exceptions/:id → approve/reject exception

Event shape

interface Event {
  id: string;
  tenantId: string;
  title: string;
  companyId?: string;     // if it's a PPT for a specific company
  type: 'ppt' | 'test' | 'workshop' | 'info_session';
  date: Date;
  location?: string;
  isOnline: boolean;
  meetingUrl?: string;
  linkedJobId?: string;   // if attending this PPT is required for a job
  
  attendanceMode: 'qr' | 'manual' | 'both';
  qrToken?: string;       // rotates every 30 seconds
  qrTokenExpiresAt?: Date;
}

Module: student-intel

Analytics and data exploration for admins.

GET    /api/tenants/:slug/intel/students      → student table with configurable columns + segmentation filter
POST   /api/tenants/:slug/intel/query         → run ad-hoc segmentation query
POST   /api/tenants/:slug/intel/export        → export result of a segmentation query
GET    /api/tenants/:slug/intel/stats         → aggregate stats: placements by sector/company/ctc/batch
GET    /api/tenants/:slug/intel/funnel/:cycleId → application funnel for a cycle

Module: companies

GET    /api/tenants/:slug/companies           → list companies (paginated, searchable)
POST   /api/tenants/:slug/companies           → admin: add company
GET    /api/companies/master                  → public master list (covers ~70% India)
POST   /api/me/companies/suggest              → student: suggest a company not in master list
PUT    /api/tenants/:slug/companies/:id/approve → admin: approve student-suggested company

API documentation

Swagger UI is available at /api/docs in non-production environments. Every route must define its request/response schema using Zod. fastify-zod generates the OpenAPI spec from these schemas automatically.

For production: export the spec to a static JSON file and host it on a documentation site (Redoc or Stoplight).


Error response format

All errors from core-api follow this shape:

{
  "error": "not_found",
  "message": "Student not found",
  "field": null,
  "requestId": "uuid"
}

For validation errors:

{
  "error": "validation_error",
  "message": "Request validation failed",
  "fields": [
    { "field": "email", "message": "Invalid email format" }
  ]
}

Standard error codes: not_found, forbidden, validation_error, conflict, internal_error, feature_disabled.


Tests

Unit tests: per module in src/modules/<module>/__tests__/
Integration tests: tests/integration/ — use a real test Postgres DB (seeded per test suite), real Redis.

Always use a separate test tenant in the DB. Never mock Prisma in integration tests — test against real DB.

Key coverage areas:

  • Prisma middleware correctly scopes all queries to tenant
  • Cross-tenant data access is impossible (try accessing another tenant's data with valid but different-tenant JWT)
  • Segmentation engine produces correct SQL for complex nested conditions
  • Domain rule check covers all matcher types
  • OTP flow: issue, verify, expiry, replay attack (same OTP twice fails)
  • Token refresh flow
  • RBAC: each route rejects roles without the required permission