initial wcag-ada

This commit is contained in:
Boki 2025-06-28 11:11:34 -04:00
parent 042b8cb83a
commit d52cfe7de2
112 changed files with 9069 additions and 0 deletions

View file

@ -0,0 +1,39 @@
import { initializeWcagConfig, getWcagConfig } from '@wcag-ada/config';
// Initialize configuration for API service
const appConfig = initializeWcagConfig('api');
// Export a config object that matches the expected interface
export const config = {
NODE_ENV: appConfig.environment as 'development' | 'production' | 'test',
PORT: appConfig.services.api.port,
// Database
DATABASE_URL: process.env.DATABASE_URL ||
`postgresql://${appConfig.database.postgres.user || 'postgres'}:${appConfig.database.postgres.password || 'postgres'}@${appConfig.database.postgres.host}:${appConfig.database.postgres.port}/${appConfig.database.postgres.database}`,
REDIS_URL: `redis://${appConfig.worker.redis.host}:${appConfig.worker.redis.port}/${appConfig.worker.redis.db}`,
// Auth
JWT_SECRET: appConfig.providers.auth.jwt.secret,
JWT_EXPIRES_IN: appConfig.providers.auth.jwt.expiresIn,
// Scanner
SCANNER_CONCURRENCY: appConfig.scanner.concurrency,
SCANNER_TIMEOUT: appConfig.scanner.timeout,
// API Rate Limiting
API_RATE_LIMIT: appConfig.services.api.rateLimit.max,
API_RATE_WINDOW: appConfig.services.api.rateLimit.windowMs,
// Storage
REPORT_STORAGE_PATH: `${appConfig.providers.storage.local.basePath}/${appConfig.providers.storage.local.reports}`,
SCREENSHOT_STORAGE_PATH: `${appConfig.providers.storage.local.basePath}/${appConfig.providers.storage.local.screenshots}`,
// CORS
CORS_ORIGIN: appConfig.services.api.cors.origin,
};
export type Config = typeof config;
// Export the full app config for advanced usage
export { appConfig };

View file

@ -0,0 +1,64 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger as honoLogger } from 'hono/logger';
import { compress } from 'hono/compress';
import { secureHeaders } from 'hono/secure-headers';
import { config, appConfig } from './config';
import { logger } from './utils/logger';
// Import routes
import { authRoutes } from './routes/auth';
import { websiteRoutes } from './routes/websites';
import { scanRoutes } from './routes/scans';
import { reportRoutes } from './routes/reports';
import { healthRoutes } from './routes/health';
// Import middleware
import { errorHandler } from './middleware/error-handler';
import { rateLimiter } from './middleware/rate-limiter';
const app = new Hono();
// Global middleware
app.use('*', honoLogger());
app.use('*', cors({
origin: config.CORS_ORIGIN || '*',
credentials: true,
}));
app.use('*', compress());
app.use('*', secureHeaders());
// Rate limiting
app.use('/api/*', rateLimiter());
// Health check (no auth required)
app.route('/health', healthRoutes);
// API routes
app.route('/api/auth', authRoutes);
app.route('/api/websites', websiteRoutes);
app.route('/api/scans', scanRoutes);
app.route('/api/reports', reportRoutes);
// 404 handler
app.notFound((c) => {
return c.json({ error: 'Not Found' }, 404);
});
// Global error handler
app.onError(errorHandler);
// Start server
const port = config.PORT || 3001;
logger.info(`🚀 WCAG-ADA API Server starting on port ${port}...`);
serve({
fetch: app.fetch,
port,
}, (info) => {
logger.info(`✅ Server is running on http://localhost:${info.port}`);
});
export default app;

View file

@ -0,0 +1,69 @@
import { Context, Next } from 'hono';
import { verify } from 'hono/jwt';
import { prisma } from '../utils/prisma';
import { config } from '../config';
export const authenticate = async (c: Context, next: Next) => {
try {
// Check for Bearer token
const authHeader = c.req.header('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const payload = await verify(token, config.JWT_SECRET, 'HS256') as {
sub: string;
email: string;
role: string;
};
c.set('userId', payload.sub);
c.set('userRole', payload.role);
c.set('authType', 'jwt');
return next();
} catch (error) {
// Invalid JWT, continue to check API key
}
}
// Check for API key
const apiKey = c.req.header('X-API-Key');
if (apiKey) {
const user = await prisma.user.findUnique({
where: {
apiKey,
isActive: true,
},
select: {
id: true,
role: true,
},
});
if (user) {
c.set('userId', user.id);
c.set('userRole', user.role);
c.set('authType', 'apiKey');
return next();
}
}
return c.json({ error: 'Unauthorized' }, 401);
} catch (error) {
return c.json({ error: 'Authentication failed' }, 401);
}
};
export const requireRole = (role: string) => {
return async (c: Context, next: Next) => {
const userRole = c.get('userRole');
if (!userRole || userRole !== role) {
return c.json({ error: 'Insufficient permissions' }, 403);
}
return next();
};
};

View file

@ -0,0 +1,59 @@
import { Context } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { ZodError } from 'zod';
import { appConfig } from '../config';
import { logger } from '../utils/logger';
export const errorHandler = (err: Error, c: Context) => {
logger.error('Error:', err);
// Handle Hono HTTP exceptions
if (err instanceof HTTPException) {
return c.json(
{ error: err.message },
err.status
);
}
// Handle Zod validation errors
if (err instanceof ZodError) {
return c.json(
{
error: 'Validation error',
details: err.errors.map(e => ({
path: e.path.join('.'),
message: e.message,
})),
},
400
);
}
// Handle Prisma errors
if (err.constructor.name === 'PrismaClientKnownRequestError') {
const prismaError = err as any;
if (prismaError.code === 'P2002') {
return c.json(
{ error: 'Duplicate entry' },
400
);
}
if (prismaError.code === 'P2025') {
return c.json(
{ error: 'Record not found' },
404
);
}
}
// Default error response
return c.json(
{
error: 'Internal server error',
message: appConfig.env === 'development' ? err.message : undefined,
},
500
);
};

View file

@ -0,0 +1,75 @@
import { Context, Next } from 'hono';
import Redis from 'ioredis';
import { config } from '../config';
import { logger } from '../utils/logger';
import { getWorkerConfig } from '@wcag-ada/config';
const workerConfig = getWorkerConfig();
const redis = new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
});
interface RateLimitOptions {
limit?: number;
window?: number; // in milliseconds
keyGenerator?: (c: Context) => string;
}
export const rateLimiter = (options: RateLimitOptions = {}) => {
const {
limit = config.API_RATE_LIMIT,
window = config.API_RATE_WINDOW,
keyGenerator = (c) => {
const userId = c.get('userId');
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
return userId ? `rate:user:${userId}` : `rate:ip:${ip}`;
},
} = options;
return async (c: Context, next: Next) => {
const key = keyGenerator(c);
try {
// Get current count
const current = await redis.get(key);
const count = current ? parseInt(current) : 0;
// Check if limit exceeded
if (count >= limit) {
const ttl = await redis.pttl(key);
c.header('X-RateLimit-Limit', limit.toString());
c.header('X-RateLimit-Remaining', '0');
c.header('X-RateLimit-Reset', new Date(Date.now() + ttl).toISOString());
return c.json(
{
error: 'Rate limit exceeded',
retryAfter: Math.ceil(ttl / 1000),
},
429
);
}
// Increment counter
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.pexpire(key, window);
await pipeline.exec();
// Set headers
c.header('X-RateLimit-Limit', limit.toString());
c.header('X-RateLimit-Remaining', (limit - count - 1).toString());
c.header('X-RateLimit-Reset', new Date(Date.now() + window).toISOString());
return next();
} catch (error) {
logger.error('Rate limiter error:', error);
// Continue on error - don't block requests due to rate limiter failure
return next();
}
};
};

View file

@ -0,0 +1,177 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { sign } from 'hono/jwt';
import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid';
import { prisma } from '../utils/prisma';
import { config } from '../config';
const authRoutes = new Hono();
// Validation schemas
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().optional(),
company: z.string().optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
// Register
authRoutes.post('/register', zValidator('json', registerSchema), async (c) => {
const { email, password, name, company } = c.req.valid('json');
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return c.json({ error: 'User already exists' }, 400);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user with API key
const user = await prisma.user.create({
data: {
email,
passwordHash,
name,
company,
apiKey: `wcag_${nanoid(32)}`,
},
select: {
id: true,
email: true,
name: true,
company: true,
apiKey: true,
role: true,
},
});
// Generate JWT
const token = await sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
config.JWT_SECRET,
'HS256'
);
return c.json({
user,
token,
});
});
// Login
authRoutes.post('/login', zValidator('json', loginSchema), async (c) => {
const { email, password } = c.req.valid('json');
// Find user
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
passwordHash: true,
name: true,
company: true,
apiKey: true,
role: true,
isActive: true,
},
});
if (!user || !user.isActive) {
return c.json({ error: 'Invalid credentials' }, 401);
}
// Verify password
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return c.json({ error: 'Invalid credentials' }, 401);
}
// Generate JWT
const token = await sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
config.JWT_SECRET,
'HS256'
);
// Remove passwordHash from response
const { passwordHash, ...userWithoutPassword } = user;
return c.json({
user: userWithoutPassword,
token,
});
});
// Refresh API key
authRoutes.post('/refresh-api-key', async (c) => {
const userId = c.get('userId');
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const newApiKey = `wcag_${nanoid(32)}`;
const user = await prisma.user.update({
where: { id: userId },
data: { apiKey: newApiKey },
select: {
id: true,
email: true,
apiKey: true,
},
});
return c.json({ apiKey: user.apiKey });
});
// Get current user
authRoutes.get('/me', async (c) => {
const userId = c.get('userId');
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
company: true,
role: true,
apiKey: true,
createdAt: true,
},
});
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
return c.json(user);
});
export { authRoutes };

View file

@ -0,0 +1,118 @@
import { Hono } from 'hono';
import { Queue } from 'bullmq';
import { prisma } from '../utils/prisma';
import Redis from 'ioredis';
import { appConfig } from '../config';
import { getWorkerConfig } from '@wcag-ada/config';
const healthRoutes = new Hono();
healthRoutes.get('/', async (c) => {
const checks = {
api: 'ok',
database: 'unknown',
redis: 'unknown',
queue: 'unknown',
};
// Check database
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = 'ok';
} catch (error) {
checks.database = 'error';
}
// Check Redis
try {
const workerConfig = getWorkerConfig();
const redis = new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
});
await redis.ping();
await redis.quit();
checks.redis = 'ok';
} catch (error) {
checks.redis = 'error';
}
// Check queue
try {
const workerConfig = getWorkerConfig();
const queue = new Queue(workerConfig.queueName, {
connection: new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
}),
});
await queue.getJobCounts();
await queue.close();
checks.queue = 'ok';
} catch (error) {
checks.queue = 'error';
}
const allHealthy = Object.values(checks).every(status => status === 'ok');
return c.json(
{
status: allHealthy ? 'healthy' : 'unhealthy',
checks,
timestamp: new Date().toISOString(),
},
allHealthy ? 200 : 503
);
});
healthRoutes.get('/stats', async (c) => {
try {
// Get queue stats
const workerConfig = getWorkerConfig();
const queue = new Queue(workerConfig.queueName, {
connection: new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
}),
});
const [waiting, active, completed, failed] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getCompletedCount(),
queue.getFailedCount(),
]);
const queueStats = { waiting, active, completed, failed };
const dbStats = await Promise.all([
prisma.user.count(),
prisma.website.count({ where: { active: true } }),
prisma.scanJob.count(),
prisma.scanResult.count(),
]);
await queue.close();
return c.json({
queue: queueStats,
database: {
users: dbStats[0],
websites: dbStats[1],
scanJobs: dbStats[2],
scanResults: dbStats[3],
},
timestamp: new Date().toISOString(),
});
} catch (error) {
return c.json({ error: 'Failed to get stats' }, 500);
}
});
export { healthRoutes };

View file

@ -0,0 +1,244 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { prisma } from '../utils/prisma';
import { authenticate } from '../middleware/auth';
import { generateReport } from '../services/report-generator';
import type { ReportPeriod } from '@wcag-ada/shared';
const reportRoutes = new Hono();
// Apply authentication to all routes
reportRoutes.use('*', authenticate);
// Validation schemas
const generateReportSchema = z.object({
websiteId: z.string(),
type: z.enum(['COMPLIANCE', 'EXECUTIVE', 'TECHNICAL', 'TREND']),
format: z.enum(['PDF', 'HTML', 'JSON', 'CSV']),
period: z.object({
start: z.string().transform(s => new Date(s)),
end: z.string().transform(s => new Date(s)),
}),
});
// Generate a new report
reportRoutes.post('/generate', zValidator('json', generateReportSchema), async (c) => {
const userId = c.get('userId');
const { websiteId, type, format, period } = c.req.valid('json');
// Check website ownership
const website = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
active: true,
},
});
if (!website) {
return c.json({ error: 'Website not found' }, 404);
}
// Get scan results for the period
const scanResults = await prisma.scanResult.findMany({
where: {
websiteId,
createdAt: {
gte: period.start,
lte: period.end,
},
},
orderBy: { createdAt: 'asc' },
});
if (scanResults.length === 0) {
return c.json({ error: 'No scan data available for the specified period' }, 400);
}
// Generate report data
const reportData = await generateReport({
website,
scanResults,
type,
format,
period,
});
// Save report
const report = await prisma.report.create({
data: {
websiteId,
userId,
type,
format,
period: period as any,
summary: reportData.summary as any,
data: reportData.data as any,
fileUrl: reportData.fileUrl,
},
});
return c.json({
report: {
id: report.id,
type: report.type,
format: report.format,
period: report.period,
fileUrl: report.fileUrl,
generatedAt: report.generatedAt,
},
}, 201);
});
// Get user's reports
reportRoutes.get('/', async (c) => {
const userId = c.get('userId');
const { page = '1', limit = '20', websiteId, type } = c.req.query();
const pageNum = parseInt(page);
const limitNum = parseInt(limit);
const skip = (pageNum - 1) * limitNum;
const where = {
userId,
...(websiteId && { websiteId }),
...(type && { type }),
};
const [reports, total] = await Promise.all([
prisma.report.findMany({
where,
skip,
take: limitNum,
orderBy: { generatedAt: 'desc' },
include: {
website: {
select: {
id: true,
name: true,
url: true,
},
},
},
}),
prisma.report.count({ where }),
]);
return c.json({
reports,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
});
// Get report by ID
reportRoutes.get('/:id', async (c) => {
const userId = c.get('userId');
const reportId = c.req.param('id');
const report = await prisma.report.findFirst({
where: {
id: reportId,
userId,
},
include: {
website: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
if (!report) {
return c.json({ error: 'Report not found' }, 404);
}
return c.json(report);
});
// Download report file
reportRoutes.get('/:id/download', async (c) => {
const userId = c.get('userId');
const reportId = c.req.param('id');
const report = await prisma.report.findFirst({
where: {
id: reportId,
userId,
},
});
if (!report || !report.fileUrl) {
return c.json({ error: 'Report file not found' }, 404);
}
// In production, this would redirect to a signed URL or serve from storage
// For now, we'll just return the file URL
return c.json({ downloadUrl: report.fileUrl });
});
// Get compliance trends
reportRoutes.get('/trends/:websiteId', async (c) => {
const userId = c.get('userId');
const websiteId = c.req.param('websiteId');
const { days = '30' } = c.req.query();
// Check website ownership
const website = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
},
});
if (!website) {
return c.json({ error: 'Website not found' }, 404);
}
const daysNum = parseInt(days);
const startDate = new Date();
startDate.setDate(startDate.getDate() - daysNum);
const scanResults = await prisma.scanResult.findMany({
where: {
websiteId,
createdAt: {
gte: startDate,
},
},
orderBy: { createdAt: 'asc' },
select: {
createdAt: true,
summary: true,
wcagCompliance: true,
},
});
const trends = scanResults.map(result => ({
date: result.createdAt,
score: (result.summary as any).score,
violationCount: (result.summary as any).violationCount,
passCount: (result.summary as any).passCount,
criticalIssues: (result.summary as any).criticalIssues,
seriousIssues: (result.summary as any).seriousIssues,
}));
return c.json({
websiteId,
period: {
start: startDate,
end: new Date(),
},
trends,
});
});
export { reportRoutes };

View file

@ -0,0 +1,283 @@
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 };

View file

@ -0,0 +1,245 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { prisma } from '../utils/prisma';
import { authenticate } from '../middleware/auth';
import type { Website, ScanSchedule } from '@wcag-ada/shared';
const websiteRoutes = new Hono();
// Apply authentication to all routes
websiteRoutes.use('*', authenticate);
// Validation schemas
const createWebsiteSchema = z.object({
name: z.string().min(1),
url: z.string().url(),
tags: z.array(z.string()).optional(),
scanSchedule: z.object({
frequency: z.enum(['manual', 'hourly', 'daily', 'weekly', 'monthly']),
dayOfWeek: z.number().min(0).max(6).optional(),
dayOfMonth: z.number().min(1).max(31).optional(),
hour: z.number().min(0).max(23).optional(),
timezone: z.string().optional(),
}).optional(),
authConfig: z.any().optional(),
scanOptions: z.any().optional(),
});
const updateWebsiteSchema = createWebsiteSchema.partial();
// List websites
websiteRoutes.get('/', async (c) => {
const userId = c.get('userId');
const { page = '1', limit = '20', search } = c.req.query();
const pageNum = parseInt(page);
const limitNum = parseInt(limit);
const skip = (pageNum - 1) * limitNum;
const where = {
userId,
...(search && {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ url: { contains: search, mode: 'insensitive' } },
],
}),
};
const [websites, total] = await Promise.all([
prisma.website.findMany({
where,
skip,
take: limitNum,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { scanResults: true },
},
},
}),
prisma.website.count({ where }),
]);
return c.json({
websites,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
});
// Get website by ID
websiteRoutes.get('/:id', async (c) => {
const userId = c.get('userId');
const websiteId = c.req.param('id');
const website = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
},
include: {
scanResults: {
take: 1,
orderBy: { createdAt: 'desc' },
},
_count: {
select: {
scanResults: true,
scanJobs: true,
},
},
},
});
if (!website) {
return c.json({ error: 'Website not found' }, 404);
}
return c.json(website);
});
// Create website
websiteRoutes.post('/', zValidator('json', createWebsiteSchema), async (c) => {
const userId = c.get('userId');
const data = c.req.valid('json');
// Check if website already exists for this user
const existing = await prisma.website.findFirst({
where: {
url: data.url,
userId,
},
});
if (existing) {
return c.json({ error: 'Website already exists' }, 400);
}
const website = await prisma.website.create({
data: {
...data,
userId,
scanSchedule: data.scanSchedule ? data.scanSchedule : undefined,
authConfig: data.authConfig ? data.authConfig : undefined,
scanOptions: data.scanOptions ? data.scanOptions : undefined,
},
});
return c.json(website, 201);
});
// Update website
websiteRoutes.patch('/:id', zValidator('json', updateWebsiteSchema), async (c) => {
const userId = c.get('userId');
const websiteId = c.req.param('id');
const data = c.req.valid('json');
// Check ownership
const existing = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
},
});
if (!existing) {
return c.json({ error: 'Website not found' }, 404);
}
const website = await prisma.website.update({
where: { id: websiteId },
data: {
...data,
scanSchedule: data.scanSchedule !== undefined ? data.scanSchedule : undefined,
authConfig: data.authConfig !== undefined ? data.authConfig : undefined,
scanOptions: data.scanOptions !== undefined ? data.scanOptions : undefined,
},
});
return c.json(website);
});
// Delete website
websiteRoutes.delete('/:id', async (c) => {
const userId = c.get('userId');
const websiteId = c.req.param('id');
// Check ownership
const existing = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
},
});
if (!existing) {
return c.json({ error: 'Website not found' }, 404);
}
// Soft delete by setting active to false
await prisma.website.update({
where: { id: websiteId },
data: { active: false },
});
return c.json({ message: 'Website deleted successfully' });
});
// Get website scan history
websiteRoutes.get('/:id/scans', async (c) => {
const userId = c.get('userId');
const websiteId = c.req.param('id');
const { page = '1', limit = '20' } = c.req.query();
const pageNum = parseInt(page);
const limitNum = parseInt(limit);
const skip = (pageNum - 1) * limitNum;
// Check ownership
const website = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
},
});
if (!website) {
return c.json({ error: 'Website not found' }, 404);
}
const [scans, total] = await Promise.all([
prisma.scanResult.findMany({
where: { websiteId },
skip,
take: limitNum,
orderBy: { createdAt: 'desc' },
select: {
id: true,
url: true,
scanDuration: true,
summary: true,
wcagCompliance: true,
createdAt: true,
},
}),
prisma.scanResult.count({ where: { websiteId } }),
]);
return c.json({
scans,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
});
export { websiteRoutes };

View file

@ -0,0 +1,271 @@
import type { Website, ScanResult, Report } from '@prisma/client';
import type { ReportPeriod, ReportSummary } from '@wcag-ada/shared';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { appConfig } from '../config';
import { getStorageConfig } from '@wcag-ada/config';
import { nanoid } from 'nanoid';
interface GenerateReportOptions {
website: Website;
scanResults: ScanResult[];
type: 'COMPLIANCE' | 'EXECUTIVE' | 'TECHNICAL' | 'TREND';
format: 'PDF' | 'HTML' | 'JSON' | 'CSV';
period: ReportPeriod;
}
interface GenerateReportResult {
summary: ReportSummary;
data: any;
fileUrl?: string;
}
export async function generateReport(
options: GenerateReportOptions
): Promise<GenerateReportResult> {
const { website, scanResults, type, format, period } = options;
// Calculate summary statistics
const summary = calculateReportSummary(scanResults, period);
// Generate report data based on type
let data: any;
switch (type) {
case 'COMPLIANCE':
data = generateComplianceData(website, scanResults, summary);
break;
case 'EXECUTIVE':
data = generateExecutiveData(website, scanResults, summary);
break;
case 'TECHNICAL':
data = generateTechnicalData(website, scanResults, summary);
break;
case 'TREND':
data = generateTrendData(website, scanResults, summary);
break;
}
// Generate file if not JSON format
let fileUrl: string | undefined;
if (format !== 'JSON') {
fileUrl = await generateReportFile(data, format, type);
}
return {
summary,
data,
fileUrl,
};
}
function calculateReportSummary(
scanResults: ScanResult[],
period: ReportPeriod
): ReportSummary {
if (scanResults.length === 0) {
return {
averageScore: 0,
totalScans: 0,
improvementRate: 0,
criticalIssuesFixed: 0,
newIssuesFound: 0,
complianceLevel: { level: 'AA', version: '2.1' },
};
}
// Calculate average score
const scores = scanResults.map(r => (r.summary as any).score || 0);
const averageScore = scores.reduce((a, b) => a + b, 0) / scores.length;
// Calculate improvement rate
const firstScore = scores[0];
const lastScore = scores[scores.length - 1];
const improvementRate = firstScore > 0 ? ((lastScore - firstScore) / firstScore) * 100 : 0;
// Count issues fixed and new issues
let criticalIssuesFixed = 0;
let newIssuesFound = 0;
if (scanResults.length >= 2) {
const firstScan = scanResults[0];
const lastScan = scanResults[scanResults.length - 1];
const firstCritical = (firstScan.summary as any).criticalIssues || 0;
const lastCritical = (lastScan.summary as any).criticalIssues || 0;
criticalIssuesFixed = Math.max(0, firstCritical - lastCritical);
newIssuesFound = Math.max(0, lastCritical - firstCritical);
}
// Get compliance level from last scan
const lastScan = scanResults[scanResults.length - 1];
const complianceLevel = (lastScan.wcagCompliance as any)?.level || { level: 'AA', version: '2.1' };
return {
averageScore: Math.round(averageScore * 100) / 100,
totalScans: scanResults.length,
improvementRate: Math.round(improvementRate * 100) / 100,
criticalIssuesFixed,
newIssuesFound,
complianceLevel,
};
}
function generateComplianceData(
website: Website,
scanResults: ScanResult[],
summary: ReportSummary
): any {
return {
website: {
name: website.name,
url: website.url,
},
summary,
latestScan: scanResults[scanResults.length - 1],
complianceHistory: scanResults.map(r => ({
date: r.createdAt,
score: (r.summary as any).score,
compliant: (r.wcagCompliance as any).isCompliant,
})),
};
}
function generateExecutiveData(
website: Website,
scanResults: ScanResult[],
summary: ReportSummary
): any {
return {
website: {
name: website.name,
url: website.url,
},
summary,
keyMetrics: {
currentScore: (scanResults[scanResults.length - 1].summary as any).score,
improvement: summary.improvementRate,
totalIssues: (scanResults[scanResults.length - 1].summary as any).violationCount,
criticalIssues: (scanResults[scanResults.length - 1].summary as any).criticalIssues,
},
recommendations: generateRecommendations(scanResults[scanResults.length - 1]),
};
}
function generateTechnicalData(
website: Website,
scanResults: ScanResult[],
summary: ReportSummary
): any {
const latestScan = scanResults[scanResults.length - 1];
return {
website: {
name: website.name,
url: website.url,
},
summary,
violations: latestScan.violations,
topIssues: getTopIssues(latestScan.violations as any[]),
fixPriority: generateFixPriority(latestScan.violations as any[]),
};
}
function generateTrendData(
website: Website,
scanResults: ScanResult[],
summary: ReportSummary
): any {
return {
website: {
name: website.name,
url: website.url,
},
summary,
trends: scanResults.map(r => ({
date: r.createdAt,
score: (r.summary as any).score,
violations: (r.summary as any).violationCount,
critical: (r.summary as any).criticalIssues,
serious: (r.summary as any).seriousIssues,
moderate: (r.summary as any).moderateIssues,
minor: (r.summary as any).minorIssues,
})),
};
}
function generateRecommendations(scanResult: ScanResult): string[] {
const violations = scanResult.violations as any[];
const recommendations: string[] = [];
// Group violations by type and generate recommendations
const criticalViolations = violations.filter(v => v.impact === 'critical');
if (criticalViolations.length > 0) {
recommendations.push('Address critical accessibility violations immediately to reduce legal risk');
}
// Add more intelligent recommendations based on violation patterns
return recommendations;
}
function getTopIssues(violations: any[]): any[] {
// Sort by impact and number of occurrences
return violations
.sort((a, b) => {
const impactOrder = { critical: 4, serious: 3, moderate: 2, minor: 1 };
const aScore = impactOrder[a.impact] * a.nodes.length;
const bScore = impactOrder[b.impact] * b.nodes.length;
return bScore - aScore;
})
.slice(0, 10);
}
function generateFixPriority(violations: any[]): any[] {
// Generate prioritized fix list
return violations
.map(v => ({
id: v.id,
impact: v.impact,
occurrences: v.nodes.length,
estimatedTime: estimateFixTime(v),
priority: calculatePriority(v),
}))
.sort((a, b) => b.priority - a.priority);
}
function estimateFixTime(violation: any): number {
const baseTime = {
critical: 30,
serious: 20,
moderate: 15,
minor: 10,
};
return (baseTime[violation.impact] || 15) * violation.nodes.length;
}
function calculatePriority(violation: any): number {
const impactScore = { critical: 100, serious: 75, moderate: 50, minor: 25 };
return (impactScore[violation.impact] || 50) * Math.log(violation.nodes.length + 1);
}
async function generateReportFile(
data: any,
format: 'PDF' | 'HTML' | 'CSV',
type: string
): Promise<string> {
const storageConfig = getStorageConfig();
const reportPath = join(storageConfig.local.basePath, storageConfig.local.reports);
// Create report directory if it doesn't exist
await mkdir(reportPath, { recursive: true });
const filename = `${type.toLowerCase()}_${nanoid()}.${format.toLowerCase()}`;
const filepath = join(reportPath, filename);
// For now, just save as JSON
// In production, you would generate actual PDF/HTML/CSV files
await writeFile(filepath, JSON.stringify(data, null, 2));
// Return relative URL (in production, this would be a CDN URL)
return `/reports/${filename}`;
}

View file

@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
import { appConfig } from '../config';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: appConfig.environment === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (appConfig.environment !== 'production') globalForPrisma.prisma = prisma;