stock-bot/apps/wcag-ada/api/src/routes/scans.ts
2025-06-28 11:11:34 -04:00

283 lines
No EOL
6.1 KiB
TypeScript

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 };