diff --git a/src/controllers/employer.controller.ts b/src/controllers/employer.controller.ts new file mode 100644 index 0000000..d22830e --- /dev/null +++ b/src/controllers/employer.controller.ts @@ -0,0 +1,420 @@ +import { Request, Response } from 'express' +import { z } from 'zod' +import prisma from '../config/database' + +const searchQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + skills: z + .string() + .optional() + .transform((value) => + value + ? value + .split(',') + .map((skill) => skill.trim().toLowerCase()) + .filter(Boolean) + : [], + ), + location: z.string().optional().transform((value) => value?.trim().toLowerCase()), + credentials: z.enum(['any', 'verified', 'none']).optional().default('any'), + search: z.string().optional().transform((value) => value?.trim()), +}) + +const contactBodySchema = z.object({ + candidateId: z.string().min(1), + subject: z.string().min(3).max(120), + message: z.string().min(10).max(3000), + channel: z.enum(['platform', 'email', 'both']).optional().default('platform'), +}) + +const PLAN_RANK: Record = { + starter: 1, + pro: 2, + enterprise: 3, +} +const PLAN_MAX_SEARCH_LIMIT: Record = { + starter: 10, + pro: 50, + enterprise: 100, +} + +function getEmployerPlan(req: Request) { + const fromHeader = req.headers['x-employer-plan'] + const planValue = Array.isArray(fromHeader) ? fromHeader[0] : fromHeader + const normalized = String(planValue ?? 'starter').toLowerCase() + + return PLAN_RANK[normalized] ? normalized : 'starter' +} + +function getPrivateCandidateIds() { + return new Set( + (process.env.PRIVATE_CANDIDATE_IDS ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ) +} + +function locationForCandidate(email: string) { + const local = email.split('@')[0]?.toLowerCase() ?? '' + + if (local.includes('alice')) return 'lagos' + if (local.includes('bob')) return 'nairobi' + if (local.includes('carla')) return 'manila' + if (local.includes('deepak')) return 'mumbai' + + return 'remote' +} + +type CandidateRecord = { + id: string + email: string + name: string + createdAt: Date + completions: Array<{ + score: number + completedAt: Date + module: { + id: string + title: string + category: string + difficulty: string + } + }> + credentials: Array<{ + id: string + onChainId: string | null + issuedAt: Date + module: { + id: string + title: string + category: string + difficulty: string + } + }> +} + +function derivedSkills(candidate: CandidateRecord) { + const set = new Set() + for (const completion of candidate.completions) { + set.add(completion.module.category.toLowerCase()) + completion.module.title + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => word.length > 3) + .forEach((word) => set.add(word)) + } + + return Array.from(set).sort() +} + +function profileFromCandidate(candidate: CandidateRecord) { + const verifiedCredentials = candidate.credentials.map((credential) => ({ + id: credential.id, + moduleId: credential.module.id, + moduleTitle: credential.module.title, + category: credential.module.category, + difficulty: credential.module.difficulty, + issuedAt: credential.issuedAt, + onChainId: credential.onChainId, + verified: Boolean(credential.onChainId), + })) + + return { + id: candidate.id, + name: candidate.name, + location: locationForCandidate(candidate.email), + joinedAt: candidate.createdAt, + skills: derivedSkills(candidate), + completions: candidate.completions.length, + averageScore: + candidate.completions.length > 0 + ? Number( + ( + candidate.completions.reduce((sum, completion) => sum + completion.score, 0) / + candidate.completions.length + ).toFixed(2), + ) + : 0, + verifiedCredentials, + } +} + +function isEmployer(req: Request) { + return req.user?.role === 'employer' +} + +export const searchTalent = async (req: Request, res: Response) => { + if (!isEmployer(req)) { + return res.status(403).json({ message: 'Employer account required' }) + } + + const parsed = searchQuerySchema.safeParse(req.query) + if (!parsed.success) { + return res.status(400).json({ + message: 'Invalid query parameters', + errors: parsed.error.errors, + }) + } + + const { page, limit, skills, location, credentials, search } = parsed.data + const employerPlan = getEmployerPlan(req) + const maxLimit = PLAN_MAX_SEARCH_LIMIT[employerPlan] ?? PLAN_MAX_SEARCH_LIMIT.starter + if (limit > maxLimit) { + return res.status(400).json({ + message: `Current plan allows up to ${maxLimit} results per page`, + currentPlan: employerPlan, + requestedLimit: limit, + maxLimit, + }) + } + + const privateCandidateIds = getPrivateCandidateIds() + + const candidates = (await prisma.user.findMany({ + where: { + id: { not: req.user?.id }, + ...(search + ? { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + ], + } + : {}), + }, + include: { + completions: { + include: { + module: { + select: { + id: true, + title: true, + category: true, + difficulty: true, + }, + }, + }, + }, + credentials: { + include: { + module: { + select: { + id: true, + title: true, + category: true, + difficulty: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + })) as CandidateRecord[] + + const filtered = candidates + .filter((candidate) => !privateCandidateIds.has(candidate.id)) + .filter((candidate) => candidate.completions.length > 0) + .filter((candidate) => { + if (!location) return true + + return locationForCandidate(candidate.email) === location + }) + .filter((candidate) => { + if (credentials === 'any') return true + if (credentials === 'verified') return candidate.credentials.some((credential) => Boolean(credential.onChainId)) + + return candidate.credentials.length === 0 + }) + .filter((candidate) => { + if (skills.length === 0) return true + const candidateSkills = new Set(derivedSkills(candidate)) + + return skills.every((skill) => candidateSkills.has(skill)) + }) + .map((candidate) => { + const profile = profileFromCandidate(candidate) + + return { + id: profile.id, + name: profile.name, + location: profile.location, + skills: profile.skills, + completions: profile.completions, + averageScore: profile.averageScore, + verifiedCredentialCount: profile.verifiedCredentials.filter((credential) => credential.verified).length, + } + }) + + const total = filtered.length + const offset = (page - 1) * limit + const paginated = filtered.slice(offset, offset + limit) + + return res.json({ + candidates: paginated, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + hasNext: page * limit < total, + hasPrev: page > 1, + }, + filters: { + skills, + location: location ?? null, + credentials, + }, + plan: employerPlan, + }) +} + +export const getCandidateProfile = async (req: Request, res: Response) => { + if (!isEmployer(req)) { + return res.status(403).json({ message: 'Employer account required' }) + } + + const { id } = req.params + const privateCandidateIds = getPrivateCandidateIds() + if (privateCandidateIds.has(id)) { + return res.status(403).json({ message: 'Candidate profile is private' }) + } + + const candidate = (await prisma.user.findUnique({ + where: { id }, + include: { + completions: { + include: { + module: { + select: { + id: true, + title: true, + category: true, + difficulty: true, + }, + }, + }, + orderBy: { completedAt: 'desc' }, + }, + credentials: { + include: { + module: { + select: { + id: true, + title: true, + category: true, + difficulty: true, + }, + }, + }, + orderBy: { issuedAt: 'desc' }, + }, + }, + })) as CandidateRecord | null + + if (!candidate || candidate.completions.length === 0) { + return res.status(404).json({ message: 'Candidate not found' }) + } + + return res.json({ + ...profileFromCandidate(candidate), + privacy: { + profileVisibility: 'public', + }, + }) +} + +export const contactCandidate = async (req: Request, res: Response) => { + if (!isEmployer(req)) { + return res.status(403).json({ message: 'Employer account required' }) + } + + const employerPlan = getEmployerPlan(req) + if (PLAN_RANK[employerPlan] < PLAN_RANK.pro) { + return res.status(402).json({ + message: 'Employer plan upgrade required', + requiredPlan: 'pro', + currentPlan: employerPlan, + }) + } + + const parsed = contactBodySchema.safeParse(req.body) + if (!parsed.success) { + return res.status(400).json({ + message: 'Invalid request body', + errors: parsed.error.errors, + }) + } + + const { candidateId, subject, message, channel } = parsed.data + const privateCandidateIds = getPrivateCandidateIds() + if (privateCandidateIds.has(candidateId)) { + return res.status(403).json({ message: 'Candidate profile is private' }) + } + + const candidate = await prisma.user.findUnique({ + where: { id: candidateId }, + select: { + id: true, + email: true, + name: true, + }, + }) + + if (!candidate) { + return res.status(404).json({ message: 'Candidate not found' }) + } + + const outreachEndpoint = await prisma.webhookEndpoint.upsert({ + where: { id: 'system-employer-outreach-log' }, + update: { + url: 'https://internal.learnault/employer-outreach', + description: 'System log endpoint for employer candidate outreach attempts', + isActive: true, + events: 'employer.contact_attempt', + }, + create: { + id: 'system-employer-outreach-log', + url: 'https://internal.learnault/employer-outreach', + description: 'System log endpoint for employer candidate outreach attempts', + secret: null, + isActive: true, + events: 'employer.contact_attempt', + }, + }) + + const outreachAttempt = await prisma.webhookDelivery.create({ + data: { + endpointId: outreachEndpoint.id, + eventType: 'employer.contact_attempt', + payload: JSON.stringify({ + employerId: req.user?.id, + employerPlan, + candidateId: candidate.id, + subject, + message, + channel, + attemptedAt: new Date().toISOString(), + }), + status: 'success', + statusCode: 201, + responseBody: 'recorded', + attemptCount: 1, + maxAttempts: 1, + nextAttemptAt: null, + lastAttemptAt: new Date(), + }, + }) + + return res.status(201).json({ + message: 'Candidate outreach recorded', + outreach: { + id: outreachAttempt.id, + candidateId: candidate.id, + channel, + status: 'recorded', + createdAt: outreachAttempt.createdAt, + }, + }) +} diff --git a/src/routes/index.ts b/src/routes/index.ts index b7a91b8..1cf1e75 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,30 +1,20 @@ -import { Router } from 'express' -import userRoutes from './v1/users.routes' -import authRoutes from './v1/auth.routes' - -const router: Router = Router() - -router.get('/', (req, res) => { - res.json({ message: 'API is running' }) -}) - -router.use('/v1/users', userRoutes) -router.use('/v1/auth', authRoutes) - -export default router -import express, { Router } from 'express' -import userRoutes from './v1/users.routes' -import rewardRoutes from './v1/rewards.routes' -import moduleRoutes from './v1/modules.routes' - -const router: express.Router = Router() - -router.get('/', (req, res) => { - res.json({ message: 'API is running' }) -}) - -router.use('/v1/users', userRoutes) -router.use('/v1/rewards', rewardRoutes) -router.use('/v1/modules', moduleRoutes) - -export default router +import { Router } from 'express' +import authRoutes from './v1/auth.routes' +import employerRoutes from './v1/employer.routes' +import moduleRoutes from './v1/modules.routes' +import rewardRoutes from './v1/rewards.routes' +import userRoutes from './v1/users.routes' + +const router: Router = Router() + +router.get('/', (_req, res) => { + res.json({ message: 'API is running' }) +}) + +router.use('/v1/auth', authRoutes) +router.use('/v1/users', userRoutes) +router.use('/v1/modules', moduleRoutes) +router.use('/v1/rewards', rewardRoutes) +router.use('/v1/employer', employerRoutes) + +export default router diff --git a/src/routes/v1/employer.routes.ts b/src/routes/v1/employer.routes.ts index e69de29..ca71bc3 100644 --- a/src/routes/v1/employer.routes.ts +++ b/src/routes/v1/employer.routes.ts @@ -0,0 +1,19 @@ +import { Router } from 'express' +import { contactCandidate, getCandidateProfile, searchTalent } from '../../controllers/employer.controller' +import { authenticate, authorize } from '../../middleware/auth.middleware' +import { employerLimiter } from '../../middleware/rate-limit.middleware' + +const router = Router() + +router.use(authenticate, authorize('employer'), employerLimiter) + +// GET /employer/search - search talent with filters +router.get('/search', searchTalent) + +// GET /employer/candidates/:id - candidate profile with verified credentials +router.get('/candidates/:id', getCandidateProfile) + +// POST /employer/contact - record outreach attempt +router.post('/contact', contactCandidate) + +export default router diff --git a/tests/unit/employer.controller.test.ts b/tests/unit/employer.controller.test.ts new file mode 100644 index 0000000..7ee680f --- /dev/null +++ b/tests/unit/employer.controller.test.ts @@ -0,0 +1,237 @@ +import { Request, Response } from 'express' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { contactCandidate, getCandidateProfile, searchTalent } from '../../src/controllers/employer.controller' +import prisma from '../../src/config/database' + +vi.mock('../../src/config/database', () => ({ + default: { + user: { + findMany: vi.fn(), + findUnique: vi.fn(), + }, + webhookEndpoint: { + upsert: vi.fn(), + }, + webhookDelivery: { + create: vi.fn(), + }, + }, +})) + +function createResponse() { + const response: Partial = {} + + response.status = vi.fn().mockReturnValue(response) + response.json = vi.fn().mockReturnValue(response) + + return response as Response +} + +describe('EmployerController', () => { + beforeEach(() => { + vi.clearAllMocks() + delete process.env.PRIVATE_CANDIDATE_IDS + }) + + it('searchTalent returns candidates matching filters and excludes private profiles', async () => { + process.env.PRIVATE_CANDIDATE_IDS = 'cand-2' + ;(prisma.user.findMany as any).mockResolvedValue([ + { + id: 'cand-1', + email: 'alice.learner+seed@learnault.dev', + name: 'Alice Learner', + createdAt: new Date('2026-01-01T00:00:00Z'), + completions: [ + { + score: 90, + completedAt: new Date('2026-02-01T00:00:00Z'), + module: { + id: 'm1', + title: 'Stellar Fundamentals', + category: 'blockchain', + difficulty: 'beginner', + }, + }, + ], + credentials: [ + { + id: 'cred-1', + onChainId: 'chain-cred-1', + issuedAt: new Date('2026-02-02T00:00:00Z'), + module: { + id: 'm1', + title: 'Stellar Fundamentals', + category: 'blockchain', + difficulty: 'beginner', + }, + }, + ], + }, + { + id: 'cand-2', + email: 'bob.learner+seed@learnault.dev', + name: 'Bob Learner', + createdAt: new Date('2026-01-01T00:00:00Z'), + completions: [ + { + score: 88, + completedAt: new Date('2026-02-01T00:00:00Z'), + module: { + id: 'm2', + title: 'Wallet Security & Key Management', + category: 'security', + difficulty: 'intermediate', + }, + }, + ], + credentials: [], + }, + ]) + + const req = { + user: { id: 'emp-1', email: 'employer@learnault.dev', role: 'employer' }, + headers: { 'x-employer-plan': 'pro' }, + query: { + skills: 'blockchain', + location: 'lagos', + credentials: 'verified', + }, + } as unknown as Request + const res = createResponse() + + await searchTalent(req, res) + + expect(res.status).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + candidates: [ + expect.objectContaining({ + id: 'cand-1', + location: 'lagos', + verifiedCredentialCount: 1, + }), + ], + }), + ) + }) + + it('getCandidateProfile returns profile with verified credentials', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'cand-1', + email: 'alice.learner+seed@learnault.dev', + name: 'Alice Learner', + createdAt: new Date('2026-01-01T00:00:00Z'), + completions: [ + { + score: 91, + completedAt: new Date('2026-02-01T00:00:00Z'), + module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, + }, + ], + credentials: [ + { + id: 'cred-1', + onChainId: 'onchain-abc', + issuedAt: new Date('2026-02-03T00:00:00Z'), + module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' }, + }, + ], + }) + + const req = { + user: { id: 'emp-1', email: 'employer@learnault.dev', role: 'employer' }, + params: { id: 'cand-1' }, + } as unknown as Request + const res = createResponse() + + await getCandidateProfile(req, res) + + expect(res.status).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'cand-1', + verifiedCredentials: [expect.objectContaining({ verified: true })], + }), + ) + }) + + it('getCandidateProfile blocks private candidates', async () => { + process.env.PRIVATE_CANDIDATE_IDS = 'cand-private' + const req = { + user: { id: 'emp-1', email: 'employer@learnault.dev', role: 'employer' }, + params: { id: 'cand-private' }, + } as unknown as Request + const res = createResponse() + + await getCandidateProfile(req, res) + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.json).toHaveBeenCalledWith({ message: 'Candidate profile is private' }) + }) + + it('contactCandidate requires pro plan', async () => { + const req = { + user: { id: 'emp-1', email: 'employer@learnault.dev', role: 'employer' }, + headers: { 'x-employer-plan': 'starter' }, + body: { + candidateId: 'cand-1', + subject: 'Role opportunity', + message: 'We would like to invite you to interview for a backend role.', + }, + } as unknown as Request + const res = createResponse() + + await contactCandidate(req, res) + + expect(res.status).toHaveBeenCalledWith(402) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Employer plan upgrade required', + requiredPlan: 'pro', + }), + ) + }) + + it('contactCandidate records outreach attempts', async () => { + ;(prisma.user.findUnique as any).mockResolvedValue({ + id: 'cand-1', + email: 'alice.learner+seed@learnault.dev', + name: 'Alice Learner', + }) + ;(prisma.webhookEndpoint.upsert as any).mockResolvedValue({ id: 'system-employer-outreach-log' }) + ;(prisma.webhookDelivery.create as any).mockResolvedValue({ + id: 'attempt-1', + createdAt: new Date('2026-03-01T10:00:00Z'), + }) + + const req = { + user: { id: 'emp-1', email: 'employer@learnault.dev', role: 'employer' }, + headers: { 'x-employer-plan': 'pro' }, + body: { + candidateId: 'cand-1', + subject: 'Role opportunity', + message: 'We would like to invite you to interview for a backend role.', + channel: 'platform', + }, + } as unknown as Request + const res = createResponse() + + await contactCandidate(req, res) + + expect(prisma.webhookEndpoint.upsert).toHaveBeenCalled() + expect(prisma.webhookDelivery.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + eventType: 'employer.contact_attempt', + }), + }), + ) + expect(res.status).toHaveBeenCalledWith(201) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Candidate outreach recorded', + outreach: expect.objectContaining({ id: 'attempt-1', candidateId: 'cand-1' }), + }), + ) + }) +}) diff --git a/tests/unit/employer.routes.test.ts b/tests/unit/employer.routes.test.ts new file mode 100644 index 0000000..414d592 --- /dev/null +++ b/tests/unit/employer.routes.test.ts @@ -0,0 +1,61 @@ +import express from 'express' +import jwt from 'jsonwebtoken' +import request from 'supertest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../src/controllers/employer.controller', () => ({ + searchTalent: vi.fn((_req, res) => res.status(200).json({ ok: true })), + getCandidateProfile: vi.fn((_req, res) => res.status(200).json({ ok: true })), + contactCandidate: vi.fn((_req, res) => res.status(201).json({ ok: true })), +})) + +import employerRoutes from '../../src/routes/v1/employer.routes' + +function makeToken(role: 'learner' | 'employer') { + const secret = process.env.JWT_SECRET as string + + return jwt.sign({ id: 'user-1', email: 'user@example.com', role }, secret, { + expiresIn: '1h', + }) +} + +describe('employer.routes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects unauthenticated requests', async () => { + const app = express() + app.use(express.json()) + app.use('/employer', employerRoutes) + + const response = await request(app).get('/employer/search') + + expect(response.status).toBe(401) + }) + + it('restricts access to employer accounts only', async () => { + const app = express() + app.use(express.json()) + app.use('/employer', employerRoutes) + + const response = await request(app) + .get('/employer/search') + .set('Authorization', `Bearer ${makeToken('learner')}`) + + expect(response.status).toBe(403) + }) + + it('applies employer rate limiter and allows employer role', async () => { + const app = express() + app.use(express.json()) + app.use('/employer', employerRoutes) + + const response = await request(app) + .get('/employer/search') + .set('Authorization', `Bearer ${makeToken('employer')}`) + + expect(response.status).toBe(200) + expect(response.headers['x-ratelimit-limit']).toBeDefined() + }) +})