Infra: Database Schema

Full Postgres 16 schema. All tables have tenant_id for multitenancy. Use Prisma migrations to manage schema changes.


Prisma schema

// packages/db/prisma/schema.prisma
 
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
// ─────────────────────────────────────────────
// TENANCY
// ─────────────────────────────────────────────
 
model Tenant {
  id          String   @id @default(uuid())
  slug        String   @unique          // "ashoka"
  name        String                   // "Ashoka University"
  logoUrl     String?
  jwtSecret   String                   // per-tenant signing secret
  config      Json     @default("{}")  // TenantConfig shape
  isActive    Boolean  @default(true)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  users               User[]
  students            Student[]
  placementCycles     PlacementCycle[]
  jobs                Job[]
  companies           Company[]
  events              Event[]
  segmentations       Segmentation[]
  apiKeys             ApiKey[]
  notifications       Notification[]
 
  @@map("tenants")
}
 
// ─────────────────────────────────────────────
// AUTH + USERS
// ─────────────────────────────────────────────
 
model User {
  id         String    @id @default(uuid())
  tenantId   String
  email      String
  name       String
  avatarUrl  String?
  role       UserRole  @default(student)
  isActive   Boolean   @default(true)
  lastLoginAt DateTime?
  createdAt  DateTime  @default(now())
  updatedAt  DateTime  @updatedAt
 
  tenant     Tenant    @relation(fields: [tenantId], references: [id])
  student    Student?
  adminProfile AdminProfile?
 
  @@unique([tenantId, email])
  @@index([tenantId])
  @@map("users")
}
 
enum UserRole {
  super_admin
  admin_l1
  admin_l2
  verifier
  student
}
 
model ApiKey {
  id          String    @id @default(uuid())
  tenantId    String
  name        String    // human label: "Ashoka ERP Integration"
  keyHash     String    // bcrypt hash of the actual key
  keyPrefix   String    // first 8 chars for display: "pk_live_abcd..."
  permissions String[]  // ["students:read", "jobs:read"]
  expiresAt   DateTime?
  lastUsedAt  DateTime?
  revokedAt   DateTime?
  createdBy   String    // userId
  createdAt   DateTime  @default(now())
 
  tenant      Tenant    @relation(fields: [tenantId], references: [id])
 
  @@index([tenantId])
  @@index([keyHash])
  @@map("api_keys")
}
 
// ─────────────────────────────────────────────
// STUDENTS
// ─────────────────────────────────────────────
 
model Student {
  id          String   @id @default(uuid())
  tenantId    String
  userId      String   @unique
  
  // Academic
  rollNumber  String?
  batchYear   Int
  major       String
  minor       String?
  cgpa        Float?
  cgpaScale   Float    @default(10.0)
  category    String?  // general, obc, sc, st, ews
  
  // Custom columns (labels stored in tenant config)
  c1          Boolean  @default(false)
  c2          Boolean  @default(false)
  c3          Boolean  @default(false)
  c4          Boolean  @default(false)
  c5          Boolean  @default(false)
  
  profileVerified  Boolean  @default(false)
  isActive         Boolean  @default(true)
  
  // Debarment
  isDebarred       Boolean  @default(false)
  debarredUntil    DateTime?
  debarReason      String?
  
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  tenant      Tenant   @relation(fields: [tenantId], references: [id])
  user        User     @relation(fields: [userId], references: [id])
  
  profileSections     ProfileSection[]
  resumes             Resume[]
  applications        Application[]
  cycleEnrollments    CycleEnrollment[]
  eventAttendances    EventAttendance[]
  verifierAssignments VerifierAssignment[]
  aiSummary           StudentAiSummary?
 
  @@index([tenantId])
  @@index([tenantId, batchYear])
  @@index([tenantId, major])
  @@map("students")
}
 
model ProfileSection {
  id           String              @id @default(uuid())
  tenantId     String
  studentId    String
  sectionType  ProfileSectionType
  data         Json                // section-specific JSON
  verificationStatus VerificationStatus @default(unverified)
  verifiedAt   DateTime?
  verifiedBy   String?
  createdAt    DateTime            @default(now())
  updatedAt    DateTime            @updatedAt
 
  student      Student  @relation(fields: [studentId], references: [id])
  documents    ProofDocument[]
  verificationRequests VerificationRequest[]
 
  @@index([tenantId, studentId])
  @@map("profile_sections")
}
 
enum ProfileSectionType {
  personal
  education
  experience
  projects
  skills
  achievements
  certifications
}
 
enum VerificationStatus {
  unverified
  pending
  verified
  rejected
}
 
model ProofDocument {
  id               String   @id @default(uuid())
  tenantId         String
  profileSectionId String
  fileName         String
  s3Key            String
  mimeType         String
  uploadedAt       DateTime @default(now())
 
  profileSection   ProfileSection @relation(fields: [profileSectionId], references: [id])
 
  @@index([tenantId, profileSectionId])
  @@map("proof_documents")
}
 
model AdminProfile {
  id         String   @id @default(uuid())
  tenantId   String
  userId     String   @unique
  department String?
  
  user       User     @relation(fields: [userId], references: [id])
  assignedCycles CycleAdmin[]
  assignedStudents VerifierAssignment[]
 
  @@index([tenantId])
  @@map("admin_profiles")
}
 
model VerifierAssignment {
  id         String   @id @default(uuid())
  tenantId   String
  verifierId String   // adminProfileId
  studentId  String
  assignedAt DateTime @default(now())
  assignedBy String   // userId
 
  verifier   AdminProfile @relation(fields: [verifierId], references: [id])
  student    Student      @relation(fields: [studentId], references: [id])
 
  @@unique([tenantId, verifierId, studentId])
  @@index([tenantId, verifierId])
  @@map("verifier_assignments")
}
 
// ─────────────────────────────────────────────
// PLACEMENT CYCLES
// ─────────────────────────────────────────────
 
model PlacementCycle {
  id                      String        @id @default(uuid())
  tenantId                String
  name                    String
  batchYear               Int
  type                    CycleType
  status                  CycleStatus   @default(draft)
  enrollmentOpen          Boolean       @default(false)
  eligibilitySegmentationId String?
  config                  Json          @default("{}")
  createdBy               String
  createdAt               DateTime      @default(now())
  updatedAt               DateTime      @updatedAt
 
  tenant                  Tenant        @relation(fields: [tenantId], references: [id])
  eligibilitySegmentation Segmentation? @relation(fields: [eligibilitySegmentationId], references: [id])
  jobs                    Job[]
  enrollments             CycleEnrollment[]
  admins                  CycleAdmin[]
 
  @@index([tenantId])
  @@map("placement_cycles")
}
 
enum CycleType {
  placement
  internship
}
 
enum CycleStatus {
  draft
  active
  closed
}
 
model CycleEnrollment {
  id         String   @id @default(uuid())
  tenantId   String
  cycleId    String
  studentId  String
  enrolledAt DateTime @default(now())
  enrolledBy String?  // null = self-enrolled
  
  cycle      PlacementCycle @relation(fields: [cycleId], references: [id])
  student    Student        @relation(fields: [studentId], references: [id])
 
  @@unique([tenantId, cycleId, studentId])
  @@index([tenantId, cycleId])
  @@map("cycle_enrollments")
}
 
model CycleAdmin {
  id         String   @id @default(uuid())
  tenantId   String
  cycleId    String
  adminId    String   // adminProfileId
  assignedAt DateTime @default(now())
 
  cycle      PlacementCycle @relation(fields: [cycleId], references: [id])
  admin      AdminProfile   @relation(fields: [adminId], references: [id])
 
  @@unique([tenantId, cycleId, adminId])
  @@map("cycle_admins")
}
 
// ─────────────────────────────────────────────
// JOBS + APPLICATIONS
// ─────────────────────────────────────────────
 
model Job {
  id                        String     @id @default(uuid())
  tenantId                  String
  cycleId                   String
  companyId                 String
  
  title                     String
  description               String
  location                  String?
  sector                    String?
  ctc                       Float?     // LPA
  ctcBreakdown              String?
  jobType                   JobType
  
  eligibilitySegmentationId String?
  acceptingApplications     Boolean    @default(false)
  deadline                  DateTime?
  
  requireResume             Boolean    @default(true)
  requireCoverLetter        Boolean    @default(false)
  requireVideo              Boolean    @default(false)
  
  status                    JobStatus  @default(draft)
  createdBy                 String
  createdAt                 DateTime   @default(now())
  updatedAt                 DateTime   @updatedAt
 
  tenant                    Tenant     @relation(fields: [tenantId], references: [id])
  cycle                     PlacementCycle @relation(fields: [cycleId], references: [id])
  company                   Company    @relation(fields: [companyId], references: [id])
  eligibilitySegmentation   Segmentation? @relation(fields: [eligibilitySegmentationId], references: [id])
  applications              Application[]
 
  @@index([tenantId])
  @@index([tenantId, cycleId])
  @@map("jobs")
}
 
enum JobType {
  fulltime
  internship
  ppo
}
 
enum JobStatus {
  draft
  published
  closed
}
 
model Application {
  id            String            @id @default(uuid())
  tenantId      String
  jobId         String
  studentId     String
  resumeId      String
  coverLetter   String?
  videoS3Key    String?
  
  status        ApplicationStatus @default(submitted)
  statusHistory Json              @default("[]")   // Array of { status, changedAt, changedBy, note }
  
  selfDebarred  Boolean           @default(false)
  autoDebarred  Boolean           @default(false)
  debarredUntil DateTime?
  
  submittedAt   DateTime          @default(now())
  updatedAt     DateTime          @updatedAt
 
  tenant        Tenant            @relation(fields: [tenantId], references: [id])
  job           Job               @relation(fields: [jobId], references: [id])
  student       Student           @relation(fields: [studentId], references: [id])
  resume        Resume            @relation(fields: [resumeId], references: [id])
 
  @@unique([tenantId, jobId, studentId])
  @@index([tenantId, studentId])
  @@index([tenantId, jobId])
  @@map("applications")
}
 
enum ApplicationStatus {
  submitted
  shortlisted
  rejected
  offered
  accepted
  withdrawn
}
 
// ─────────────────────────────────────────────
// RESUMES
// ─────────────────────────────────────────────
 
model Resume {
  id                    String   @id @default(uuid())
  tenantId              String
  studentId             String
  name                  String   // "Resume v1", "McKinsey variant"
  data                  Json     // Full resume JSON (see product/resume.md for schema)
  isAiVariant           Boolean  @default(false)
  baseResumeId          String?  // if this is a variant of another resume
  unverifiedChangeKeys  String[] // JSON paths of unverified edits: ["experience.0.bullets.2"]
  createdAt             DateTime @default(now())
  updatedAt             DateTime @updatedAt
 
  tenant       Tenant   @relation(fields: [tenantId], references: [id])
  student      Student  @relation(fields: [studentId], references: [id])
  applications Application[]
  verificationRequests VerificationRequest[]
 
  @@index([tenantId, studentId])
  @@map("resumes")
}
 
model VerificationRequest {
  id               String             @id @default(uuid())
  tenantId         String
  studentId        String
  resumeId         String?
  profileSectionId String?
  changeKeys       String[]           // which keys/bullets need verification
  status           VerificationStatus @default(pending)
  assignedTo       String?            // verifier userId
  verifierNote     String?
  resolvedAt       DateTime?
  resolvedBy       String?
  createdAt        DateTime           @default(now())
 
  @@index([tenantId, assignedTo])
  @@index([tenantId, status])
  @@map("verification_requests")
}
 
// ─────────────────────────────────────────────
// SEGMENTATION
// ─────────────────────────────────────────────
 
model Segmentation {
  id         String   @id @default(uuid())
  tenantId   String
  name       String?  // null = ad-hoc, not saved
  definition Json     // SegmentationDefinition shape
  createdBy  String
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
 
  tenant     Tenant   @relation(fields: [tenantId], references: [id])
  cyclesEligibility  PlacementCycle[]
  jobsEligibility    Job[]
 
  @@index([tenantId])
  @@map("segmentations")
}
 
// ─────────────────────────────────────────────
// EVENTS
// ─────────────────────────────────────────────
 
model Event {
  id             String      @id @default(uuid())
  tenantId       String
  title          String
  companyId      String?
  type           EventType
  date           DateTime
  location       String?
  isOnline       Boolean     @default(false)
  meetingUrl     String?
  linkedJobId    String?
  attendanceMode AttendanceMode @default(manual)
  qrToken        String?
  qrTokenExpiresAt DateTime?
  createdBy      String
  createdAt      DateTime    @default(now())
  updatedAt      DateTime    @updatedAt
 
  tenant         Tenant      @relation(fields: [tenantId], references: [id])
  attendances    EventAttendance[]
  exceptionRequests EventExceptionRequest[]
 
  @@index([tenantId])
  @@map("events")
}
 
enum EventType {
  ppt
  test
  workshop
  info_session
}
 
enum AttendanceMode {
  qr
  manual
  both
}
 
model EventAttendance {
  id          String   @id @default(uuid())
  tenantId    String
  eventId     String
  studentId   String
  markedAt    DateTime @default(now())
  markedBy    String?  // null = self check-in via QR
  method      String   // "qr" or "manual"
 
  event       Event    @relation(fields: [eventId], references: [id])
  student     Student  @relation(fields: [studentId], references: [id])
 
  @@unique([tenantId, eventId, studentId])
  @@index([tenantId, eventId])
  @@map("event_attendances")
}
 
model EventExceptionRequest {
  id         String   @id @default(uuid())
  tenantId   String
  eventId    String
  studentId  String
  reason     String
  status     String   @default("pending")  // pending, approved, rejected
  reviewedBy String?
  reviewNote String?
  createdAt  DateTime @default(now())
 
  event      Event    @relation(fields: [eventId], references: [id])
 
  @@index([tenantId, eventId])
  @@map("event_exception_requests")
}
 
// ─────────────────────────────────────────────
// COMPANIES
// ─────────────────────────────────────────────
 
model Company {
  id          String   @id @default(uuid())
  tenantId    String?  // null = master list (shared across tenants)
  name        String
  sector      String?
  website     String?
  logoUrl     String?
  isApproved  Boolean  @default(true)  // false for student-suggested, pending admin approval
  suggestedBy String?  // studentId if student-suggested
  createdAt   DateTime @default(now())
 
  tenant      Tenant?  @relation(fields: [tenantId], references: [id])
  jobs        Job[]
 
  @@index([tenantId])
  @@index([name])
  @@map("companies")
}
 
// ─────────────────────────────────────────────
// NOTIFICATIONS (in-app)
// ─────────────────────────────────────────────
 
model Notification {
  id         String   @id @default(uuid())
  tenantId   String
  userId     String
  title      String
  body       String
  link       String?
  type       String   @default("info")
  read       Boolean  @default(false)
  createdAt  DateTime @default(now())
 
  tenant     Tenant   @relation(fields: [tenantId], references: [id])
 
  @@index([tenantId, userId, read])
  @@map("notifications")
}
 
// ─────────────────────────────────────────────
// AI
// ─────────────────────────────────────────────
 
model StudentAiSummary {
  id          String   @id @default(uuid())
  tenantId    String
  studentId   String   @unique
  summary     String
  generatedAt DateTime @default(now())
 
  student     Student  @relation(fields: [studentId], references: [id])
 
  @@index([tenantId])
  @@map("student_ai_summaries")
}

Key indexes

Beyond what Prisma creates for @id and @unique, always add:

-- Multitenancy — on every frequently queried table
CREATE INDEX idx_students_tenant ON students(tenant_id);
CREATE INDEX idx_jobs_tenant_cycle ON jobs(tenant_id, cycle_id);
CREATE INDEX idx_applications_tenant_student ON applications(tenant_id, student_id);
CREATE INDEX idx_applications_tenant_job ON applications(tenant_id, job_id);
 
-- Full-text search on students
CREATE INDEX idx_students_fts ON students USING gin(
  to_tsvector('english', coalesce(name, '') || ' ' || coalesce(email, '') || ' ' || coalesce(roll_number, ''))
);
 
-- Trigram on job title + company name for ILIKE search
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_jobs_title_trgm ON jobs USING gin(title gin_trgm_ops);

Row Level Security (second layer of defence)

ALTER TABLE students ENABLE ROW LEVEL SECURITY;
ALTER TABLE jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE applications ENABLE ROW LEVEL SECURITY;
-- (enable on all tables)
 
-- Policy: app_user can only see rows for their tenant
CREATE POLICY tenant_isolation ON students
  USING (tenant_id = current_setting('app.tenant_id')::uuid);
 
-- Set this at connection time in Prisma:
-- SET app.tenant_id = 'uuid';

This is a safety net. The Prisma middleware already filters by tenant_id — RLS catches any case where the middleware is bypassed.


Seed data

// packages/db/prisma/seed.ts
 
async function main() {
  // Create Ashoka tenant
  const tenant = await prisma.tenant.create({
    data: {
      slug: 'ashoka',
      name: 'Ashoka University',
      jwtSecret: 'dev-secret-ashoka-change-in-prod',
      config: {
        featureFlags: { aiResume: true, publicApi: true },
        domainRules: {
          allowedDomains: ['ashoka.edu.in'],
          matchers: [
            { contains: '_ug25', role: 'student' },
            { contains: '_ug2024', role: 'student' },
          ],
          allowAnyFromDomain: false,
        },
        customColumns: {
          c1Label: 'CPP attended',
          c2Label: 'IPP attended',
          c3Label: '', c4Label: '', c5Label: '',
        },
      },
    },
  });
 
  // Create super admin user
  await prisma.user.create({
    data: {
      tenantId: tenant.id,
      email: 'admin@ashoka.edu.in',
      name: 'Placement Admin',
      role: 'super_admin',
    },
  });
 
  // Seed master company list (top 100 Indian companies)
  await prisma.company.createMany({
    data: masterCompanies.map(c => ({ ...c, tenantId: null, isApproved: true })),
  });
}