import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { Queue } from 'bullmq'; import Redis from 'ioredis'; import { prisma } from '../utils/prisma'; import { authenticate } from '../middleware/auth'; import { getWorkerConfig } from '@wcag-ada/config'; import type { AccessibilityScanOptions } from '@wcag-ada/shared'; const scanRoutes = new Hono(); // Get worker config for queue connection const workerConfig = getWorkerConfig(); // Create queue connection const scanQueue = new Queue('accessibility-scans', { connection: new Redis({ host: workerConfig.redis.host, port: workerConfig.redis.port, password: workerConfig.redis.password, db: workerConfig.redis.db, }), }); // Apply authentication to all routes scanRoutes.use('*', authenticate); // Validation schemas const createScanSchema = z.object({ websiteId: z.string(), url: z.string().url().optional(), options: z.object({ wcagLevel: z.object({ level: z.enum(['A', 'AA', 'AAA']), version: z.enum(['2.0', '2.1', '2.2']), }).optional(), viewport: z.object({ width: z.number(), height: z.number(), deviceScaleFactor: z.number().optional(), isMobile: z.boolean().optional(), }).optional(), includeScreenshots: z.boolean().optional(), excludeSelectors: z.array(z.string()).optional(), waitForSelector: z.string().optional(), timeout: z.number().optional(), }).optional(), }); // Start a new scan scanRoutes.post('/', zValidator('json', createScanSchema), async (c) => { const userId = c.get('userId'); const { websiteId, url, options } = c.req.valid('json'); // Get website const website = await prisma.website.findFirst({ where: { id: websiteId, userId, active: true, }, }); if (!website) { return c.json({ error: 'Website not found' }, 404); } // Merge options with website defaults const scanOptions: AccessibilityScanOptions = { url: url || website.url, ...website.scanOptions as any, ...options, authenticate: website.authConfig as any, }; // Create scan job const job = await prisma.scanJob.create({ data: { websiteId, userId, url: scanOptions.url, options: scanOptions as any, status: 'PENDING', }, }); // Queue the scan await scanQueue.add('scan', { jobId: job.id, websiteId, userId, options: scanOptions, }, { jobId: job.id, }); return c.json({ job: { id: job.id, status: job.status, url: job.url, scheduledAt: job.scheduledAt, }, message: 'Scan queued successfully', }, 202); }); // Get scan status scanRoutes.get('/:id', async (c) => { const userId = c.get('userId'); const scanId = c.req.param('id'); const job = await prisma.scanJob.findFirst({ where: { id: scanId, userId, }, include: { result: { select: { id: true, summary: true, wcagCompliance: true, createdAt: true, }, }, }, }); if (!job) { return c.json({ error: 'Scan not found' }, 404); } return c.json(job); }); // Get scan result scanRoutes.get('/:id/result', async (c) => { const userId = c.get('userId'); const scanId = c.req.param('id'); const result = await prisma.scanResult.findFirst({ where: { jobId: scanId, job: { userId, }, }, }); if (!result) { return c.json({ error: 'Scan result not found' }, 404); } return c.json(result); }); // Get scan violations scanRoutes.get('/:id/violations', async (c) => { const userId = c.get('userId'); const scanId = c.req.param('id'); const { impact, tag } = c.req.query(); const result = await prisma.scanResult.findFirst({ where: { jobId: scanId, job: { userId, }, }, select: { violations: true, }, }); if (!result) { return c.json({ error: 'Scan result not found' }, 404); } let violations = result.violations as any[]; // Filter by impact if provided if (impact) { violations = violations.filter(v => v.impact === impact); } // Filter by tag if provided if (tag) { violations = violations.filter(v => v.tags.includes(tag)); } return c.json({ violations }); }); // Cancel a pending scan scanRoutes.delete('/:id', async (c) => { const userId = c.get('userId'); const scanId = c.req.param('id'); const job = await prisma.scanJob.findFirst({ where: { id: scanId, userId, status: 'PENDING', }, }); if (!job) { return c.json({ error: 'Scan not found or cannot be cancelled' }, 404); } // Update job status await prisma.scanJob.update({ where: { id: scanId }, data: { status: 'FAILED', error: 'Cancelled by user', completedAt: new Date(), }, }); // Remove from queue if possible const queueJob = await scanQueue.getJob(scanId); if (queueJob) { await queueJob.remove(); } return c.json({ message: 'Scan cancelled successfully' }); }); // Get user's scan history scanRoutes.get('/', async (c) => { const userId = c.get('userId'); const { page = '1', limit = '20', status, websiteId } = c.req.query(); const pageNum = parseInt(page); const limitNum = parseInt(limit); const skip = (pageNum - 1) * limitNum; const where = { userId, ...(status && { status }), ...(websiteId && { websiteId }), }; const [jobs, total] = await Promise.all([ prisma.scanJob.findMany({ where, skip, take: limitNum, orderBy: { createdAt: 'desc' }, include: { website: { select: { id: true, name: true, url: true, }, }, result: { select: { id: true, summary: true, }, }, }, }), prisma.scanJob.count({ where }), ]); return c.json({ scans: jobs, pagination: { page: pageNum, limit: limitNum, total, totalPages: Math.ceil(total / limitNum), }, }); }); export { scanRoutes };