P99soft is a multi-tenant SaaS recruitment platform built for enterprise hiring teams. It follows the WAT architecture (Workflows, Agents, Tools) with a clean separation between the AI orchestration layer and deterministic execution scripts.
┌─────────────────────────────────────────────────────────────────────┐
│ Browser / Client │
│ React 18 + TypeScript + Vite │
└──────────────────────────────┬──────────────────────────────────────┘
│ HTTPS / REST + SSE
┌──────────────────────────────▼──────────────────────────────────────┐
│ Nginx (Reverse Proxy) │
│ Static assets → /usr/share/nginx/html │
│ /api/* → proxy_pass http://backend:8000 │
└──────────────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────────────┐
│ FastAPI Backend (Python 3.12) │
│ │
│ ┌─────────────────┐ ┌──────────────┐ ┌────────────────────┐ │
│ │ API Layer │ │ Services │ │ Background Jobs │ │
│ │ /api/v1/... │ │ ai_service │ │ threading.Thread │ │
│ │ auth, users │ │ sourcing │ │ (sourcing jobs) │ │
│ │ candidates │ │ candidate │ └────────────────────┘ │
│ │ jobs, pipeline │ │ pipeline │ │
│ │ sourcing, ai │ └──────────────┘ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Core Layer │ │
│ │ config.py database.py security.py deps.py │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────┬──────────────────────────────────────────────────────────────┘
│ │ │
┌──────▼──────┐ ┌────────▼──────┐ ┌───────────▼──────┐
│ PostgreSQL │ │ Redis │ │ Claude API │
│ (primary │ │ (rate limit) │ │ (claude-sonnet) │
│ database) │ └───────────────┘ └──────────────────┘
└─────────────┘
│
┌──────▼──────────────────────────────────────────────────────────────┐
│ tools/ (Python scripts) │
│ search_linkedin.py search_github.py scrape_naukri.py │
│ search_indeed.py search_twitter.py dedupe_and_score.py │
│ export_to_excel.py read_google_sheet.py │
└─────────────────────────────────────────────────────────────────────┘
Single-database, row-level isolation. Every table that holds customer data has an org_id UUID column. The RBAC middleware injects current_user.org_id into every query — making cross-tenant data leakage structurally impossible through the API layer.
organizations
└─── users (org_id FK)
└─── user_roles (org_id FK)
└─── job_requisitions (org_id FK)
└─── candidates (org_id FK)
└─── pipeline_entries (org_id FK)
└─── sourcing_jobs (org_id FK)
└─── audit_logs (org_id)
# Every query is always scoped to current org
result = await db.execute(
select(Candidate).where(
Candidate.id == candidate_id,
Candidate.org_id == current_user.org_id # ← always enforced
)
)
See RBAC.md for full details.
6 system roles (seeded at migration time):
| Role | Level |
|---|---|
super_admin |
Platform-wide |
org_admin |
Full org access |
hiring_manager |
Jobs + pipeline |
recruiter |
Sourcing + candidates |
interviewer |
Read + feedback only |
viewer |
Read-only |
20 granular permissions covering: candidates, jobs, pipeline, sourcing, analytics, users, roles, ai, settings, integrations.
Permission enforcement is a FastAPI dependency factory:
@router.delete("/{id}")
async def delete_candidate(
id: uuid.UUID,
user = Depends(require_permission("candidates:delete")) # ← enforced here
):
...
The AIService class in app/services/ai_service.py wraps the Anthropic SDK with:
cache_control: ephemeral) on system prompts — reduces costs on repeated callsanthropic.AsyncAnthropic| Endpoint | Description |
|---|---|
POST /api/v1/ai/score-candidate |
0-10 score with reasoning, strengths, gaps |
POST /api/v1/ai/parse-jd |
Extract skills, seniority, requirements from raw JD text |
POST /api/v1/ai/interview-questions |
10 questions by round type (technical/behavioral/culture_fit/hr) |
POST /api/v1/ai/draft-offer-letter |
Full offer letter with subject line |
POST /api/v1/ai/recruiter-email |
Stage-appropriate recruiter email |
POST /api/v1/ai/enhance-queries |
AI-optimized Boolean search queries per sourcing channel |
User triggers sourcing
│
▼
POST /api/v1/sourcing/run
│
├─ Creates SourcingJob record (status=pending)
├─ Spawns background thread
└─ Returns {job_ids: [...]}
│
▼
Background Thread (run_sourcing_job)
│
┌─────────┴──────────────────────────────────────────────┐
│ 1. search_linkedin.py → 60 candidates │
│ 2. search_github.py → 40 candidates │
│ 3. scrape_naukri.py → 45 candidates │
│ 4. search_indeed.py → 40 candidates │
│ 5. search_twitter.py → 25 candidates │
└─────────┬──────────────────────────────────────────────┘
│
┌─────────▼──────────┐
│ dedupe_and_score.py │ ← fuzzy dedup + rule-based scoring
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ export_to_excel.py │ ← Excel with hyperlinks + color scores
└─────────┬──────────┘
│
Updates SourcingJob (status=done, candidates_found=N)
│
▼
User polls GET /api/v1/sourcing/jobs/{id}
or streams SSE GET /api/v1/sourcing/jobs/{id}/stream
backend/
├── app/
│ ├── main.py # App factory: CORS, audit middleware, rate limiter, routes
│ ├── api/v1/
│ │ ├── router.py # Aggregate all v1 routes under /api/v1
│ │ ├── auth.py # Login, refresh, logout, profile
│ │ ├── users.py # User CRUD (org-scoped)
│ │ ├── roles.py # Role + permission management
│ │ ├── candidates.py # Candidate CRUD + notes
│ │ ├── jobs.py # Job requisitions CRUD
│ │ ├── pipeline.py # Pipeline stages, interview rounds, offers
│ │ ├── sourcing.py # Sourcing jobs + SSE log stream
│ │ ├── analytics.py # Funnel stats, source quality
│ │ └── ai.py # All Claude AI endpoints
│ ├── core/
│ │ ├── config.py # All settings via pydantic-settings + .env
│ │ ├── database.py # Async SQLAlchemy engine + session factory
│ │ ├── security.py # JWT, bcrypt, Fernet encryption
│ │ └── deps.py # FastAPI deps: get_current_user, require_permission
│ ├── models/ # SQLAlchemy models (one file per domain)
│ ├── schemas/ # Pydantic v2 request/response models
│ ├── services/
│ │ └── ai_service.py # Claude API integration (all 6 AI features)
│ └── middleware/
│ └── audit.py # Auto-audit-log all mutating requests
├── alembic/ # Database migrations
│ └── versions/001_initial_schema.py # All tables + seed data
├── tests/
├── Dockerfile
├── requirements.txt
└── .env.example
frontend/src/
├── components/
│ ├── ui/ # Design system primitives
│ │ ├── Button.tsx # variant: primary|secondary|ghost|danger|outline
│ │ ├── Input.tsx # with label, error, helpText, icons
│ │ ├── Card.tsx # Card, CardHeader, CardBody, CardFooter
│ │ ├── Badge.tsx # status badges + stageBadgeVariant helper
│ │ ├── Modal.tsx # accessible dialog with Escape/overlay close
│ │ ├── Spinner.tsx # Spinner + FullPageSpinner
│ │ ├── Avatar.tsx # image or initials with color hash
│ │ ├── Skeleton.tsx # loading placeholders + TableSkeleton
│ │ ├── Toast.tsx # toast store + ToastContainer
│ │ └── index.ts # barrel export — import from 'components/ui'
│ ├── layout/
│ │ ├── AppShell.tsx # sidebar + main content wrapper
│ │ ├── Sidebar.tsx # role-filtered nav using usePermission
│ │ └── PageHeader.tsx # title + breadcrumbs + action slot
│ └── rbac/
│ ├── PermissionGate.tsx # render-level permission check
│ └── RoleGuard.tsx # route-level redirect guard
├── services/
│ ├── api.ts # Axios instance with JWT inject + 401 refresh
│ ├── auth.service.ts # login, logout, profile
│ └── ai.service.ts # all 6 AI endpoints
├── stores/
│ └── authStore.ts # user, token, refreshToken, permissions[], hasPermission()
├── hooks/
│ ├── usePermission.ts # hasPermission(), hasRole(), isAdmin()
│ └── useAuth.ts # login/logout with navigation + toast
└── pages/ # Route-level page components
| Layer | Mechanism |
|---|---|
| Auth | JWT access tokens (15 min) + rotating refresh tokens (7 days, hashed in DB) |
| Passwords | bcrypt, cost factor 12 |
| API keys at rest | Fernet symmetric encryption |
| Tenant isolation | org_id injected into every DB query |
| Rate limiting | SlowAPI: 100 req/min per IP |
| CORS | Explicit allowed origins list |
| Input validation | Pydantic v2 on all request schemas |
| SQL injection | SQLAlchemy ORM only, no raw f-string queries |
| Audit log | All POST/PUT/DELETE auto-logged with user, IP, resource |