initial wcag-ada
This commit is contained in:
parent
042b8cb83a
commit
d52cfe7de2
112 changed files with 9069 additions and 0 deletions
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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue