Finalize: CLAUDE.md, report viewer, tsconfig, type fixes
Some checks failed
CI / Backend — lint + test (push) Has been cancelled
CI / Frontend — lint + typecheck (push) Has been cancelled
CI / Build + push Docker images (push) Has been cancelled

- Update CLAUDE.md with full project structure and conventions
- Add report viewer page: score ring, WCAG compliance cards, issue list with severity filter, next steps, export + auto-fix buttons, real-time polling
- Add Alembic script.py.mako template (required for alembic revision --autogenerate)
- Add frontend/tsconfig.json + postcss.config.js (Next.js build requirements)
- Fix TypeScript error in supabase/server.ts (explicit CookieOptions type)
- Upgrade Next.js 15.3.2 → 15.5.18 (cache poisoning CVE fix)
- Update .gitignore: frontend build artifacts, backend venv, tsbuildinfo

TypeScript: 0 errors (npx tsc --noEmit passes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-19 15:20:43 +01:00
parent 5cbbcc6e5e
commit eb9cdbf639
6 changed files with 382 additions and 90 deletions

15
.gitignore vendored
View file

@ -49,3 +49,18 @@ htmlcov/
uploads/
results/
logs/
# Frontend (Next.js)
frontend/node_modules/
frontend/.next/
frontend/.env.local
frontend/out/
# Backend (uv / Python)
backend/.venv/
backend/.env
# Supabase local
supabase/.branches/
supabase/.temp/
frontend/tsconfig.tsbuildinfo

157
CLAUDE.md
View file

@ -1,100 +1,81 @@
# CLAUDE.md
# PDF Accessibility SaaS — Claude Code Briefing
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
## Project Overview
A SaaS product by **Aimpress** that checks PDF documents for WCAG 2.1 AA / PDF/UA-1 compliance.
Regulatory tailwind: EU Accessibility Act (June 2025) requires accessible documents from banks, e-commerce, e-learning, and government.
AI-powered PDF accessibility checker that validates documents against WCAG 2.1 Level A & AA standards. Combines traditional PDF analysis (pypdf, pdfplumber) with AI models (Anthropic Claude, Google Cloud Vision) for ~95% automated WCAG coverage. Branded for "Oliver" (Montserrat font, black/#FFC407 palette).
## Stack
## Commands
- **Backend:** FastAPI + Python (single language — PHP removed)
- **Frontend:** Next.js 15 + shadcn/ui + Tailwind CSS
- **Auth:** Supabase Auth (email + magic link)
- **DB:** PostgreSQL 16 + Alembic migrations + Row-Level Security
- **Queue:** Celery + Redis
- **Storage:** MinIO (S3-compatible, self-hosted)
- **Deploy:** Docker Compose + Caddy on homelab Proxmox VM
- **CI:** Forgejo Actions
### Testing
```bash
source venv/bin/activate
pytest tests/ -v # Run all tests (31 tests)
pytest tests/ --cov=. --cov-report=html # With coverage report
pytest tests/test_checker.py -v # Single test file
pytest tests/ -m "not integration" # Skip integration tests
## Core AI/Checking Engine (DO NOT MODIFY without strong reason)
- `enterprise_pdf_checker.py` — 30+ WCAG checks, ~2000 lines, uses Claude Sonnet + Google Vision
- `pdf_remediation.py` — auto-fix engine
- `report_generator.py` — converts JSON results → HTML/PDF report
These are the product's moat. All other files wrap them.
## Project structure
```
PDF-accessibility-saas/
├── backend/ # FastAPI app
│ ├── app/
│ │ ├── config.py # pydantic-settings
│ │ ├── deps.py # Supabase JWT auth dependency
│ │ ├── db.py # SQLAlchemy async engine
│ │ ├── main.py # FastAPI app entry
│ │ ├── routers/ # jobs, auth, billing
│ │ ├── services/ # checker, storage, queue
│ │ └── models/ # job, workspace
│ ├── alembic/ # DB migrations
│ └── pyproject.toml # uv-based deps
├── frontend/ # Next.js 15
│ ├── app/
│ │ ├── (marketing)/ # Landing, Pricing (public)
│ │ ├── (auth)/ # Login, Signup (Supabase)
│ │ └── (app)/ # Dashboard, Jobs, Settings (auth-gated)
│ └── lib/supabase/ # SSR client/server helpers
├── enterprise_pdf_checker.py # Core WCAG engine (from Oliver, reused 1:1)
├── pdf_remediation.py # Auto-fix engine
├── report_generator.py # HTML/PDF report generator
├── docker-compose.yml # Dev: postgres, redis, minio, api, celery
├── docker-compose.prod.yml # Prod: + nextjs, caddy
├── Caddyfile # Auto-SSL for pdfaccess.ai-impress.com
└── .forgejo/workflows/ci.yml # CI: test → build → deploy
```
### Running Locally
```bash
source venv/bin/activate
php -S localhost:8000 # Start PHP dev server
```
## Conventions
### Docker
```bash
docker-compose up # Development stack
docker-compose -f docker-compose.prod.yml up -d # Production stack
docker-compose exec worker pytest tests/ -v # Tests in container
```
- All env vars via `pydantic-settings` in `backend/app/config.py`
- Logging via `structlog` (JSON in prod, pretty in dev)
- HTTP clients via `httpx` (async)
- Migrations via `alembic` — never raw ALTER TABLE in code
- Auth: Supabase JWT verified in `backend/app/deps.py::get_current_user()`
- Every endpoint requires workspace isolation (workspace_id from JWT)
- RLS active on jobs, workspaces, workspace_members, usage_events
### CLI Usage
```bash
python enterprise_pdf_checker.py document.pdf --output report.json # Full check
python enterprise_pdf_checker.py document.pdf --quick # Skip AI checks
python pdf_remediation.py document.pdf --output fixed.pdf --all # Auto-remediate
```
## Branding
## Architecture
- Primary color: `#6366F1` (indigo)
- Font: Inter
- Product name: "Aimpress PDF Accessibility"
- Tagline: "WCAG-compliant PDFs in 60 seconds"
- Domain: `pdfaccess.ai-impress.com`
### Three Interfaces
- **Web UI** (`index.html` + `js/` + `css/`) — vanilla JS, drag-drop upload, visual inspector
- **REST API** (`api.php`) — PHP endpoints: upload, check, status, result, remediate, download
- **CLI** (`enterprise_pdf_checker.py`) — direct Python execution
## Pricing
### Request Flow (Docker/Production)
1. `api.php` receives upload, validates via `auth.php`, saves to `uploads/`
2. Job pushed to Redis queue (`pdf:queue`) and tracked in PostgreSQL
3. `worker.py` daemon pops jobs, runs `EnterprisePDFChecker.check_all()`
4. Results written to `results/{job_id}.result.json`, DB updated
5. Client polls `api.php?action=status` then fetches results
### Key Source Files
| File | Purpose |
|------|---------|
| `enterprise_pdf_checker.py` | Core engine — 30+ WCAG checks, AI image analysis, scoring |
| `api.php` | REST API — file handling, job queue integration, CORS |
| `auth.php` | Authentication — Bearer/X-API-Key, dev mode localhost bypass |
| `worker.py` | Background daemon — Redis queue consumer, graceful shutdown |
| `db_manager.py` | PostgreSQL ORM — jobs CRUD, audit logging |
| `redis_queue.py` | Redis operations — job queue, status tracking, rate limiting |
| `pdf_remediation.py` | Auto-fix — metadata, tagging, language tags |
| `retry_helper.py` | Exponential backoff for external API calls |
| `report_generator.py` | Result formatting and report generation |
| `logger_config.py` | Structured logging with rotation (10MB max) |
| `cleanup.py` | File retention cleanup (24h for uploads/results) |
### Data Layer
- **PostgreSQL**`jobs` table (status, score, grade, result JSON), `audit_log` table. Schema in `db/init.sql`
- **Redis** — Job queue (`pdf:queue`), status tracking (`pdf:status:*`), rate limiting (`pdf:rate:*`)
### External APIs
- **Anthropic Claude 3.5 Sonnet** — alt text validation, image classification, text-in-images
- **Google Cloud Vision** — OCR, text detection
- **veraPDF** (optional) — PDF/UA-1 compliance validation
### Frontend Structure
`js/app.js` (controller), `js/upload.js` (drag-drop), `js/api.js` (HTTP client), `js/results.js` (display), `js/page-viewer.js` (PDF inspector), `js/batch.js` (batch processing), `js/utils.js` (helpers)
## Tech Stack
- **Backend**: Python 3.11 (processing), PHP 8.2 (API)
- **Frontend**: Vanilla HTML/CSS/JS
- **Database**: PostgreSQL 16, Redis 7
- **Infrastructure**: Docker, Nginx/Apache, PHP-FPM
- **System deps**: Tesseract OCR, Poppler, Ghostscript
## Configuration
Environment variables via `.env` (see `.env.example`). Key settings:
- `ANTHROPIC_API_KEY` / `GOOGLE_API_KEY` — AI API credentials
- `DEV_MODE=true` — bypasses auth for localhost requests
- `DB_HOST`, `DB_PORT`, `REDIS_HOST`, `REDIS_PORT` — infrastructure endpoints
- Production uses ports 1220 (Redis) and 1221 (PostgreSQL) to avoid host conflicts
## Testing
- pytest with markers: `integration`, `slow`, `api`
- Config in `pytest.ini`
- Fixtures in `tests/conftest.py`
- Sample PDFs in `Test_files/`
- No linter currently configured
- Free: 5 PDF/month
- Pro ($29/mo): 100 PDF + auto-fix
- Business ($149/mo): unlimited + API + team

View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,264 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
interface Issue {
severity: string;
category: string;
description: string;
recommendation: string;
wcag_criterion: string;
page_number?: number;
}
interface JobResult {
id: string;
filename: string;
status: string;
accessibility_score: number | null;
result: {
accessibility_score: number;
severity_counts: Record<string, number>;
wcag_compliance: { level_a: boolean; level_aa: boolean };
issues: Issue[];
next_steps: Array<{ priority: number; category: string; action: string; wcag: string }>;
score_breakdown?: { adjusted: boolean };
stats?: { duration: number; api_calls: number; total_cost_estimate: number };
} | null;
error_message: string | null;
}
const SEV_COLOR: Record<string, string> = {
CRITICAL: "bg-red-100 text-red-700 border-red-200",
ERROR: "bg-orange-100 text-orange-700 border-orange-200",
WARNING: "bg-yellow-100 text-yellow-700 border-yellow-200",
INFO: "bg-blue-100 text-blue-700 border-blue-200",
};
const SEV_ICON: Record<string, string> = {
CRITICAL: "🚨", ERROR: "❌", WARNING: "⚠️", INFO: "",
};
function ScoreRing({ score }: { score: number }) {
const color = score >= 80 ? "#10b981" : score >= 60 ? "#f59e0b" : "#ef4444";
const grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "F";
return (
<div className="flex items-center gap-6">
<div className="relative w-24 h-24 flex items-center justify-center">
<svg className="absolute" width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r="42" fill="none" stroke="#e5e7eb" strokeWidth="8" />
<circle
cx="48" cy="48" r="42" fill="none"
stroke={color} strokeWidth="8"
strokeDasharray={`${(score / 100) * 263.9} 263.9`}
strokeLinecap="round"
transform="rotate(-90 48 48)"
/>
</svg>
<div className="text-center">
<div className="text-2xl font-extrabold text-gray-900">{score}</div>
<div className="text-xs text-gray-400 font-medium">/ 100</div>
</div>
</div>
<div>
<div className="text-5xl font-extrabold" style={{ color }}>{grade}</div>
<div className="text-sm text-gray-500 mt-1">Grade</div>
</div>
</div>
);
}
export default function JobReportPage() {
const { id } = useParams<{ id: string }>();
const [job, setJob] = useState<JobResult | null>(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<string>("ALL");
useEffect(() => {
let interval: NodeJS.Timeout;
const poll = async () => {
const res = await fetch(`/api/v1/jobs/${id}`, { credentials: "include" });
if (!res.ok) return;
const data = await res.json();
setJob(data);
setLoading(false);
if (data.status === "completed" || data.status === "failed") {
clearInterval(interval);
}
};
poll();
interval = setInterval(poll, 3000);
return () => clearInterval(interval);
}, [id]);
async function handleRemediate() {
await fetch(`/api/v1/jobs/${id}/remediate`, { method: "POST", credentials: "include" });
}
async function downloadReport(format: "html" | "json") {
const res = await fetch(`/api/v1/jobs/${id}?format=${format}`, { credentials: "include" });
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `report-${id}.${format}`;
a.click();
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center text-gray-400">
<div className="text-4xl mb-3 animate-spin"></div>
<p>Loading report...</p>
</div>
</div>
);
}
if (!job) return <div className="text-red-500">Job not found</div>;
if (job.status === "pending" || job.status === "processing") {
return (
<div className="max-w-lg mx-auto mt-16 text-center">
<div className="text-5xl mb-4 animate-pulse">🔍</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Checking accessibility...</h2>
<p className="text-gray-500 text-sm">{job.filename}</p>
<div className="mt-6 bg-gray-100 rounded-full h-2 overflow-hidden">
<div className="bg-brand-500 h-2 rounded-full animate-pulse w-2/3" />
</div>
<p className="text-xs text-gray-400 mt-3">Running 30+ WCAG checks · This takes 1560 seconds</p>
</div>
);
}
if (job.status === "failed") {
return (
<div className="max-w-lg bg-red-50 border border-red-200 rounded-2xl p-8 text-center mt-8">
<div className="text-4xl mb-3"></div>
<h2 className="text-lg font-bold text-red-800">Check failed</h2>
<p className="text-red-700 text-sm mt-2">{job.error_message || "Unknown error"}</p>
</div>
);
}
const r = job.result!;
const sc = r.severity_counts || {};
const issues = (r.issues || []).filter((i) => filter === "ALL" || i.severity === filter);
return (
<div className="max-w-4xl">
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 truncate max-w-lg">{job.filename}</h1>
<p className="text-sm text-gray-400 mt-1">WCAG 2.1 AA + PDF/UA-1 Accessibility Report</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => downloadReport("html")} className="text-sm px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50">Export HTML</button>
<button onClick={() => downloadReport("json")} className="text-sm px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50">Export JSON</button>
<button onClick={handleRemediate} className="text-sm px-3 py-1.5 bg-brand-500 text-white rounded-lg hover:bg-brand-600">Auto-fix PDF</button>
</div>
</div>
{/* Score + WCAG compliance */}
<div className="grid md:grid-cols-2 gap-4 mb-6">
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<ScoreRing score={r.accessibility_score} />
<div className="flex gap-4 mt-4 text-sm">
{Object.entries(sc).map(([sev, count]) => (
<div key={sev} className="text-center">
<div className="font-bold text-gray-900">{count as number}</div>
<div className="text-gray-400 text-xs">{sev.toLowerCase()}</div>
</div>
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">WCAG 2.1 Conformance</h3>
{[
{ label: "Level A", pass: r.wcag_compliance?.level_a },
{ label: "Level AA", pass: r.wcag_compliance?.level_aa },
].map((lvl) => (
<div key={lvl.label} className={`flex items-center justify-between p-3 rounded-xl mb-2 ${lvl.pass ? "bg-green-50 border border-green-200" : "bg-red-50 border border-red-200"}`}>
<span className={`font-semibold text-sm ${lvl.pass ? "text-green-800" : "text-red-800"}`}>WCAG 2.1 {lvl.label}</span>
<span className={`font-bold text-lg ${lvl.pass ? "text-green-600" : "text-red-600"}`}>{lvl.pass ? "✓ Pass" : "✗ Fail"}</span>
</div>
))}
</div>
</div>
{/* Issues */}
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h3 className="font-semibold text-gray-900">Issues ({r.issues?.length || 0})</h3>
<div className="flex gap-1">
{["ALL", "CRITICAL", "ERROR", "WARNING", "INFO"].map((sev) => (
<button
key={sev}
onClick={() => setFilter(sev)}
className={`text-xs px-3 py-1 rounded-full font-medium transition-colors ${
filter === sev ? "bg-brand-500 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{sev === "ALL" ? `All (${r.issues?.length || 0})` : `${SEV_ICON[sev]} ${sev} (${sc[sev] || 0})`}
</button>
))}
</div>
</div>
{issues.length === 0 ? (
<div className="py-12 text-center text-gray-400 text-sm">No issues for this filter</div>
) : (
<div className="divide-y divide-gray-100">
{issues.map((issue, i) => (
<div key={i} className="px-6 py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-start gap-3">
<span className={`mt-0.5 text-xs px-2 py-0.5 rounded-full border font-medium whitespace-nowrap ${SEV_COLOR[issue.severity] || "bg-gray-100 text-gray-600"}`}>
{SEV_ICON[issue.severity]} {issue.severity}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-gray-500">{issue.category}</span>
{issue.wcag_criterion && (
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded font-mono">{issue.wcag_criterion}</span>
)}
{issue.page_number && (
<span className="text-xs text-gray-400">Page {issue.page_number}</span>
)}
</div>
<p className="text-sm text-gray-900">{issue.description}</p>
{issue.recommendation && (
<p className="text-xs text-gray-500 mt-1 italic">{issue.recommendation}</p>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Next steps */}
{r.next_steps && r.next_steps.length > 0 && (
<div className="bg-white rounded-2xl border border-gray-200 mt-4 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h3 className="font-semibold text-gray-900">Recommended Next Steps</h3>
</div>
<div className="divide-y divide-gray-100">
{r.next_steps.slice(0, 10).map((step, i) => (
<div key={i} className="px-6 py-3 flex items-start gap-3 hover:bg-gray-50">
<span className="text-xs font-bold text-gray-400 mt-0.5 w-4">{i + 1}</span>
<div className="flex-1">
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded font-mono mr-2">{step.wcag}</span>
<span className="text-sm text-gray-700">{step.action}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -1,4 +1,4 @@
import { createServerClient } from "@supabase/ssr";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
@ -9,7 +9,7 @@ export async function createClient() {
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
setAll: (cookiesToSet: Array<{ name: string; value: string; options?: CookieOptions }>) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};