initial wcag-ada
This commit is contained in:
parent
042b8cb83a
commit
d52cfe7de2
112 changed files with 9069 additions and 0 deletions
39
apps/wcag-ada/api/src/config.ts
Normal file
39
apps/wcag-ada/api/src/config.ts
Normal 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 };
|
||||
64
apps/wcag-ada/api/src/index.ts
Normal file
64
apps/wcag-ada/api/src/index.ts
Normal 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;
|
||||
69
apps/wcag-ada/api/src/middleware/auth.ts
Normal file
69
apps/wcag-ada/api/src/middleware/auth.ts
Normal 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();
|
||||
};
|
||||
};
|
||||
59
apps/wcag-ada/api/src/middleware/error-handler.ts
Normal file
59
apps/wcag-ada/api/src/middleware/error-handler.ts
Normal 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
|
||||
);
|
||||
};
|
||||
75
apps/wcag-ada/api/src/middleware/rate-limiter.ts
Normal file
75
apps/wcag-ada/api/src/middleware/rate-limiter.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
};
|
||||
177
apps/wcag-ada/api/src/routes/auth.ts
Normal file
177
apps/wcag-ada/api/src/routes/auth.ts
Normal 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 };
|
||||
118
apps/wcag-ada/api/src/routes/health.ts
Normal file
118
apps/wcag-ada/api/src/routes/health.ts
Normal 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 };
|
||||
244
apps/wcag-ada/api/src/routes/reports.ts
Normal file
244
apps/wcag-ada/api/src/routes/reports.ts
Normal 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 };
|
||||
283
apps/wcag-ada/api/src/routes/scans.ts
Normal file
283
apps/wcag-ada/api/src/routes/scans.ts
Normal 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 };
|
||||
245
apps/wcag-ada/api/src/routes/websites.ts
Normal file
245
apps/wcag-ada/api/src/routes/websites.ts
Normal 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 };
|
||||
271
apps/wcag-ada/api/src/services/report-generator.ts
Normal file
271
apps/wcag-ada/api/src/services/report-generator.ts
Normal 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}`;
|
||||
}
|
||||
12
apps/wcag-ada/api/src/utils/prisma.ts
Normal file
12
apps/wcag-ada/api/src/utils/prisma.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue