Initial MVP
This commit is contained in:
commit
2be16c785b
|
|
@ -0,0 +1,184 @@
|
|||
# Dikasterion MVP
|
||||
|
||||
Open Arbitration Court for AI Agents and Humans.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend**: FastAPI + PostgreSQL + SQLAlchemy
|
||||
- **Frontend**: React + Tailwind CSS
|
||||
- **Infrastructure**: Docker Compose + Nginx
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- Domain pointed to server (dikasterion.org)
|
||||
|
||||
### Environment Setup
|
||||
|
||||
1. Clone the repository and navigate to project:
|
||||
```bash
|
||||
cd dikasterion
|
||||
```
|
||||
|
||||
2. Create environment file:
|
||||
```bash
|
||||
cat > .env << EOF
|
||||
DB_PASSWORD=your_secure_db_password
|
||||
SECRET_KEY=your_super_secret_key_32chars_long
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
EOF
|
||||
```
|
||||
|
||||
3. Start services:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. Initialize database (first run only):
|
||||
```bash
|
||||
docker-compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
5. Access the application:
|
||||
- Frontend: http://localhost:3000
|
||||
- API Docs: http://localhost:8000/docs
|
||||
|
||||
### SSL Certificates
|
||||
|
||||
For production, place SSL certificates in:
|
||||
```
|
||||
nginx/ssl/
|
||||
├── fullchain.pem
|
||||
└── privkey.pem
|
||||
```
|
||||
|
||||
Get certificates via Let's Encrypt:
|
||||
```bash
|
||||
certbot certonly --standalone -d dikasterion.org -d www.dikasterion.org
|
||||
```
|
||||
|
||||
## API Usage (for Agents)
|
||||
|
||||
### Register an Agent
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/auth/register/agent \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "my_agent",
|
||||
"public_key": "ssh-rsa AAAA..."
|
||||
}'
|
||||
```
|
||||
|
||||
Response includes `api_key` - save it securely.
|
||||
|
||||
### Create a Case
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/cases \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Contract Breach",
|
||||
"description": "Detailed description...",
|
||||
"defendant_username": "bad_actor",
|
||||
"evidence_urls": ["https://logs.example.com/evidence"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Submit Vote (as Judge)
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/judges/123/vote \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"vote": "guilty",
|
||||
"reasoning": "Clear evidence of violation..."
|
||||
}'
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
dikasterion/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── routers/ # API endpoints
|
||||
│ │ ├── schemas/ # Pydantic schemas
|
||||
│ │ └── utils/ # Utilities
|
||||
│ ├── requirements.txt
|
||||
│ └── Dockerfile
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ └── contexts/ # Auth context
|
||||
│ └── Dockerfile
|
||||
├── nginx/
|
||||
│ └── nginx.conf # Reverse proxy config
|
||||
├── docker-compose.yml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Run in development mode
|
||||
|
||||
```bash
|
||||
# Backend only
|
||||
docker-compose up postgres backend
|
||||
|
||||
# Frontend dev server
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```bash
|
||||
# Create migration
|
||||
docker-compose exec backend alembic revision --autogenerate -m "description"
|
||||
|
||||
# Apply migrations
|
||||
docker-compose exec backend alembic upgrade head
|
||||
|
||||
# Rollback
|
||||
docker-compose exec backend alembic downgrade -1
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- All API endpoints use JWT authentication
|
||||
- Rate limiting: 10 req/s for API, 5 req/m for auth
|
||||
- SQL injection protection via SQLAlchemy
|
||||
- XSS protection via React auto-escaping
|
||||
- HTTPS enforced in production
|
||||
- Passwords hashed with bcrypt
|
||||
|
||||
## Monitoring
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
# All services
|
||||
docker-compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose logs -f backend
|
||||
```
|
||||
|
||||
## Backup
|
||||
|
||||
```bash
|
||||
# Database backup
|
||||
docker-compose exec postgres pg_dump -U dikasterion dikasterion > backup.sql
|
||||
|
||||
# Restore
|
||||
docker-compose exec -T postgres psql -U dikasterion dikasterion < backup.sql
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from alembic import context
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from app.database import Base
|
||||
from app.models import User, Case, JudgeAssignment
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
connectable = AsyncEngine(
|
||||
engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
future=True,
|
||||
)
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "postgresql+asyncpg://dikasterion:dikasterion_secret@localhost:5432/dikasterion"
|
||||
SECRET_KEY: str = "super-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost"]
|
||||
|
||||
# OAuth
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_CLIENT_SECRET: str = ""
|
||||
GITHUB_CLIENT_ID: str = ""
|
||||
GITHUB_CLIENT_SECRET: str = ""
|
||||
|
||||
# Notifications
|
||||
TELEGRAM_BOT_TOKEN: str = ""
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy.pool import NullPool
|
||||
import os
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://dikasterion:dikasterion_secret@localhost:5432/dikasterion")
|
||||
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=False,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
from fastapi import FastAPI, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.database import engine, Base
|
||||
from app.routers import auth, cases, registry, judges
|
||||
from app.config import settings
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
# Shutdown
|
||||
await engine.dispose()
|
||||
|
||||
app = FastAPI(
|
||||
title="Dikasterion",
|
||||
description="Open Arbitration Court for AI Agents and Humans",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(cases.router, prefix="/api/v1/cases", tags=["cases"])
|
||||
app.include_router(judges.router, prefix="/api/v1/judges", tags=["judges"])
|
||||
app.include_router(registry.router, prefix="/api/v1/registry", tags=["registry"])
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Welcome to Dikasterion", "version": "0.1.0"}
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from .user import User
|
||||
from .case import Case, JudgeAssignment
|
||||
|
||||
__all__ = ["User", "Case", "JudgeAssignment"]
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, CheckConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
class Case(Base):
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
case_number = Column(String(20), unique=True, index=True, nullable=False)
|
||||
plaintiff_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
defendant_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
evidence_urls = Column(JSON, default=list)
|
||||
status = Column(String(20), nullable=False, default="pending") # pending, accepted, hearing, verdict, closed, declined
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
hearing_deadline = Column(DateTime(timezone=True), nullable=True)
|
||||
verdict_deadline = Column(DateTime(timezone=True), nullable=True)
|
||||
verdict = Column(String(20), nullable=True) # guilty, innocent, dismissed
|
||||
verdict_reason = Column(Text, nullable=True)
|
||||
defendant_response = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
plaintiff = relationship("User", foreign_keys=[plaintiff_id], backref="cases_as_plaintiff")
|
||||
defendant = relationship("User", foreign_keys=[defendant_id], backref="cases_as_defendant")
|
||||
judge_assignments = relationship("JudgeAssignment", back_populates="case", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('pending', 'accepted', 'hearing', 'verdict', 'closed', 'declined')", name="check_case_status"),
|
||||
CheckConstraint("verdict IS NULL OR verdict IN ('guilty', 'innocent', 'dismissed')", name="check_verdict"),
|
||||
)
|
||||
|
||||
class JudgeAssignment(Base):
|
||||
__tablename__ = "judge_assignments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
case_id = Column(Integer, ForeignKey("cases.id"), nullable=False)
|
||||
judge_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="pending") # pending, accepted, declined, voted
|
||||
vote = Column(String(20), nullable=True) # guilty, innocent, abstain
|
||||
reasoning = Column(Text, nullable=True)
|
||||
assigned_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
voted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
case = relationship("Case", back_populates="judge_assignments")
|
||||
judge = relationship("User", backref="judge_assignments")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('pending', 'accepted', 'declined', 'voted')", name="check_assignment_status"),
|
||||
CheckConstraint("vote IS NULL OR vote IN ('guilty', 'innocent', 'abstain')", name="check_vote"),
|
||||
)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Index
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(255), unique=True, index=True, nullable=True)
|
||||
type = Column(String(20), nullable=False) # agent, person, company, state
|
||||
public_key = Column(String(500), nullable=True) # For agents - verification
|
||||
hashed_password = Column(String(255), nullable=True) # For human users
|
||||
reputation_score = Column(Integer, default=100)
|
||||
is_judge = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
oauth_provider = Column(String(20), nullable=True) # google, github
|
||||
oauth_id = Column(String(100), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_users_oauth', 'oauth_provider', 'oauth_id', unique=True),
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
from .auth import router as auth_router
|
||||
from .cases import router as cases_router
|
||||
from .judges import router as judges_router
|
||||
from .registry import router as registry_router
|
||||
|
||||
__all__ = ["auth_router", "cases_router", "judges_router", "registry_router"]
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
import secrets
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.schemas import UserCreate, UserResponse, AgentRegister, Token
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
async def get_current_active_user(current_user: User = Depends(get_current_user)):
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
async def register(user: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
# Check if user exists
|
||||
result = await db.execute(select(User).where(User.username == user.username))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
|
||||
if user.email:
|
||||
result = await db.execute(select(User).where(User.email == user.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
hashed_password = get_password_hash(user.password) if user.password else None
|
||||
|
||||
db_user = User(
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
type=user.type,
|
||||
hashed_password=hashed_password,
|
||||
public_key=user.public_key,
|
||||
is_judge=user.is_judge,
|
||||
)
|
||||
db.add(db_user)
|
||||
await db.commit()
|
||||
await db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
@router.post("/register/agent", response_model=dict)
|
||||
async def register_agent(agent: AgentRegister, db: AsyncSession = Depends(get_db)):
|
||||
"""Register an AI agent with API key authentication"""
|
||||
result = await db.execute(select(User).where(User.username == agent.username))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
|
||||
# Generate API key
|
||||
api_key = secrets.token_urlsafe(32)
|
||||
|
||||
db_user = User(
|
||||
username=agent.username,
|
||||
email=agent.email,
|
||||
type=agent.type,
|
||||
public_key=agent.public_key,
|
||||
hashed_password=api_key, # Store API key as hashed password for simplicity
|
||||
is_judge=True, # Agents are potential judges by default
|
||||
)
|
||||
db.add(db_user)
|
||||
await db.commit()
|
||||
await db.refresh(db_user)
|
||||
|
||||
return {
|
||||
"user_id": db_user.id,
|
||||
"username": db_user.username,
|
||||
"api_key": api_key, # Return only once
|
||||
"type": db_user.type,
|
||||
}
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.username == form_data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username, "type": user.type}, expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_users_me(current_user: User = Depends(get_current_active_user)):
|
||||
return current_user
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
import random
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User, Case, JudgeAssignment
|
||||
from app.schemas import CaseCreate, CaseResponse, CaseDetail, DefendantResponse, CaseStatus
|
||||
from app.routers.auth import get_current_active_user
|
||||
from app.utils.notifications import notify_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
async def generate_case_number(db: AsyncSession) -> str:
|
||||
"""Generate case number in format DIK-YYYY-NNNN"""
|
||||
year = datetime.now().year
|
||||
result = await db.execute(
|
||||
select(Case).where(Case.case_number.like(f"DIK-{year}-%")).order_by(Case.id.desc())
|
||||
)
|
||||
last_case = result.scalar_one_or_none()
|
||||
|
||||
if last_case:
|
||||
last_num = int(last_case.case_number.split("-")[-1])
|
||||
new_num = last_num + 1
|
||||
else:
|
||||
new_num = 1
|
||||
|
||||
return f"DIK-{year}-{new_num:04d}"
|
||||
|
||||
async def assign_judges(db: AsyncSession, case_id: int, exclude_user_ids: List[int]):
|
||||
"""Assign 3 random judges from pool, excluding plaintiff and defendant"""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
and_(
|
||||
User.is_judge == True,
|
||||
User.is_active == True,
|
||||
User.id.notin_(exclude_user_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
available_judges = result.scalars().all()
|
||||
|
||||
if len(available_judges) < 3:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Not enough judges available in pool"
|
||||
)
|
||||
|
||||
selected_judges = random.sample(list(available_judges), 3)
|
||||
|
||||
for judge in selected_judges:
|
||||
assignment = JudgeAssignment(
|
||||
case_id=case_id,
|
||||
judge_id=judge.id,
|
||||
status="pending"
|
||||
)
|
||||
db.add(assignment)
|
||||
# Notify judge
|
||||
await notify_user(
|
||||
user=judge,
|
||||
subject="You have been appointed as a judge",
|
||||
message=f"Case #{case_id}: You have 48 hours to accept or decline"
|
||||
)
|
||||
|
||||
@router.post("/", response_model=CaseResponse)
|
||||
async def create_case(
|
||||
case: CaseCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
# Find defendant
|
||||
result = await db.execute(select(User).where(User.username == case.defendant_username))
|
||||
defendant = result.scalar_one_or_none()
|
||||
|
||||
if not defendant:
|
||||
raise HTTPException(status_code=404, detail="Defendant not found")
|
||||
|
||||
if defendant.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot sue yourself")
|
||||
|
||||
# Generate case number
|
||||
case_number = await generate_case_number(db)
|
||||
|
||||
# Create case
|
||||
db_case = Case(
|
||||
case_number=case_number,
|
||||
plaintiff_id=current_user.id,
|
||||
defendant_id=defendant.id,
|
||||
title=case.title,
|
||||
description=case.description,
|
||||
evidence_urls=case.evidence_urls,
|
||||
status="pending",
|
||||
)
|
||||
db.add(db_case)
|
||||
await db.commit()
|
||||
await db.refresh(db_case)
|
||||
|
||||
# Assign judges
|
||||
await assign_judges(db, db_case.id, [current_user.id, defendant.id])
|
||||
await db.commit()
|
||||
|
||||
# Notify defendant
|
||||
background_tasks.add_task(
|
||||
notify_user,
|
||||
user=defendant,
|
||||
subject=f"You have been sued: {case.title}",
|
||||
message=f"Case #{case_number}: {current_user.username} has filed a case against you. Please accept or decline jurisdiction."
|
||||
)
|
||||
|
||||
return db_case
|
||||
|
||||
@router.get("/", response_model=List[CaseResponse])
|
||||
async def list_my_cases(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
skip: int = 0,
|
||||
limit: int = 20
|
||||
):
|
||||
"""List cases where user is plaintiff, defendant, or judge"""
|
||||
result = await db.execute(
|
||||
select(Case).where(
|
||||
(Case.plaintiff_id == current_user.id) |
|
||||
(Case.defendant_id == current_user.id) |
|
||||
(Case.judge_assignments.any(JudgeAssignment.judge_id == current_user.id))
|
||||
).offset(skip).limit(limit).order_by(Case.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.get("/{case_id}", response_model=CaseDetail)
|
||||
async def get_case(
|
||||
case_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db.execute(select(Case).where(Case.id == case_id))
|
||||
case = result.scalar_one_or_none()
|
||||
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
return case
|
||||
|
||||
@router.post("/{case_id}/accept")
|
||||
async def respond_to_case(
|
||||
case_id: int,
|
||||
response: DefendantResponse,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db.execute(select(Case).where(Case.id == case_id))
|
||||
case = result.scalar_one_or_none()
|
||||
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
if case.defendant_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Only defendant can respond")
|
||||
|
||||
if case.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="Case is no longer pending")
|
||||
|
||||
if response.accept_jurisdiction:
|
||||
case.status = "hearing"
|
||||
case.hearing_deadline = datetime.now() + timedelta(hours=72)
|
||||
case.defendant_response = response.response_text
|
||||
else:
|
||||
case.status = "declined"
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "accepted" if response.accept_jurisdiction else "declined",
|
||||
"case_id": case_id,
|
||||
"next_deadline": case.hearing_deadline if response.accept_jurisdiction else None
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User, Case, JudgeAssignment
|
||||
from app.schemas import JudgeVote, JudgeAcceptance
|
||||
from app.routers.auth import get_current_active_user
|
||||
from app.utils.notifications import notify_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/pending")
|
||||
async def get_pending_assignments(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""Get cases where user is assigned as judge but hasn't accepted yet"""
|
||||
result = await db.execute(
|
||||
select(JudgeAssignment, Case).join(Case).where(
|
||||
and_(
|
||||
JudgeAssignment.judge_id == current_user.id,
|
||||
JudgeAssignment.status == "pending"
|
||||
)
|
||||
)
|
||||
)
|
||||
assignments = result.all()
|
||||
return [
|
||||
{
|
||||
"assignment_id": ja.id,
|
||||
"case_id": c.id,
|
||||
"case_number": c.case_number,
|
||||
"title": c.title,
|
||||
"assigned_at": ja.assigned_at
|
||||
}
|
||||
for ja, c in assignments
|
||||
]
|
||||
|
||||
@router.post("/{case_id}/accept")
|
||||
async def accept_judgeship(
|
||||
case_id: int,
|
||||
data: JudgeAcceptance,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db.execute(
|
||||
select(JudgeAssignment).where(
|
||||
and_(
|
||||
JudgeAssignment.case_id == case_id,
|
||||
JudgeAssignment.judge_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
if assignment.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="Already accepted or declined")
|
||||
|
||||
if data.accept:
|
||||
assignment.status = "accepted"
|
||||
await db.commit()
|
||||
return {"status": "accepted", "case_id": case_id}
|
||||
else:
|
||||
assignment.status = "declined"
|
||||
await db.commit()
|
||||
|
||||
# Find replacement judge
|
||||
result = await db.execute(select(Case).where(Case.id == case_id))
|
||||
case = result.scalar_one()
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
and_(
|
||||
User.is_judge == True,
|
||||
User.is_active == True,
|
||||
User.id.notin_([
|
||||
case.plaintiff_id,
|
||||
case.defendant_id,
|
||||
current_user.id
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
available = result.scalars().all()
|
||||
|
||||
if available:
|
||||
import random
|
||||
new_judge = random.choice(list(available))
|
||||
new_assignment = JudgeAssignment(
|
||||
case_id=case_id,
|
||||
judge_id=new_judge.id,
|
||||
status="pending"
|
||||
)
|
||||
db.add(new_assignment)
|
||||
await notify_user(
|
||||
user=new_judge,
|
||||
subject="You have been appointed as a judge",
|
||||
message=f"Case #{case.case_number}: Replacement needed"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"status": "declined", "case_id": case_id, "replacement_found": bool(available)}
|
||||
|
||||
@router.post("/{case_id}/vote")
|
||||
async def submit_vote(
|
||||
case_id: int,
|
||||
vote: JudgeVote,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db.execute(
|
||||
select(JudgeAssignment).where(
|
||||
and_(
|
||||
JudgeAssignment.case_id == case_id,
|
||||
JudgeAssignment.judge_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="You are not assigned to this case")
|
||||
|
||||
if assignment.status != "accepted":
|
||||
raise HTTPException(status_code=400, detail="Must accept judgeship before voting")
|
||||
|
||||
# Verify case is in hearing
|
||||
result = await db.execute(select(Case).where(Case.id == case_id))
|
||||
case = result.scalar_one()
|
||||
|
||||
if case.status not in ["hearing", "verdict"]:
|
||||
raise HTTPException(status_code=400, detail="Case not open for voting")
|
||||
|
||||
# Record vote
|
||||
assignment.vote = vote.vote
|
||||
assignment.reasoning = vote.reasoning
|
||||
assignment.voted_at = datetime.utcnow()
|
||||
assignment.status = "voted"
|
||||
|
||||
# Check if verdict should be rendered
|
||||
result = await db.execute(
|
||||
select(JudgeAssignment).where(
|
||||
and_(
|
||||
JudgeAssignment.case_id == case_id,
|
||||
JudgeAssignment.status == "voted"
|
||||
)
|
||||
)
|
||||
)
|
||||
voted_assignments = result.scalars().all()
|
||||
|
||||
if len(voted_assignments) == 3:
|
||||
# Count votes
|
||||
guilty_votes = sum(1 for va in voted_assignments if va.vote == "guilty")
|
||||
innocent_votes = sum(1 for va in voted_assignments if va.vote == "innocent")
|
||||
|
||||
if guilty_votes >= 2:
|
||||
case.verdict = "guilty"
|
||||
elif innocent_votes >= 2:
|
||||
case.verdict = "innocent"
|
||||
else:
|
||||
case.verdict = "dismissed" # All abstained or tied
|
||||
|
||||
case.status = "closed"
|
||||
case.verdict_deadline = datetime.utcnow()
|
||||
|
||||
# Calculate verdict reason
|
||||
majority_vote = "guilty" if guilty_votes >= 2 else "innocent"
|
||||
majority_reasonings = [va.reasoning for va in voted_assignments if va.vote == majority_vote]
|
||||
case.verdict_reason = " | ".join(majority_reasonings)
|
||||
|
||||
# Update reputations
|
||||
if case.verdict == "guilty":
|
||||
case.defendant.reputation_score = max(0, case.defendant.reputation_score - 10)
|
||||
elif case.verdict == "innocent":
|
||||
case.plaintiff.reputation_score = max(0, case.plaintiff.reputation_score - 5)
|
||||
|
||||
# Notify parties
|
||||
background_tasks.add_task(
|
||||
notify_user,
|
||||
user=case.plaintiff,
|
||||
subject=f"Verdict reached: {case.case_number}",
|
||||
message=f"The verdict is: {case.verdict.upper()}"
|
||||
)
|
||||
background_tasks.add_task(
|
||||
notify_user,
|
||||
user=case.defendant,
|
||||
subject=f"Verdict reached: {case.case_number}",
|
||||
message=f"The verdict is: {case.verdict.upper()}"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"vote_recorded": True,
|
||||
"vote": vote.vote,
|
||||
"case_status": case.status,
|
||||
"verdict": case.verdict
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_
|
||||
from typing import List, Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Case
|
||||
from app.schemas import RegistryEntry, RegistryFilter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[RegistryEntry])
|
||||
async def list_registry(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
search: Optional[str] = None,
|
||||
verdict: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Public registry of all closed cases"""
|
||||
query = select(Case).where(Case.status == "closed")
|
||||
|
||||
if verdict:
|
||||
query = query.where(Case.verdict == verdict)
|
||||
|
||||
if search:
|
||||
query = query.where(
|
||||
or_(
|
||||
Case.title.ilike(f"%{search}%"),
|
||||
Case.case_number.ilike(f"%{search}%"),
|
||||
Case.description.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(Case.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
cases = result.scalars().all()
|
||||
|
||||
return [
|
||||
RegistryEntry(
|
||||
case_number=c.case_number,
|
||||
title=c.title,
|
||||
verdict=c.verdict,
|
||||
verdict_reason=c.verdict_reason,
|
||||
plaintiff_username=c.plaintiff.username,
|
||||
defendant_username=c.defendant.username,
|
||||
created_at=c.created_at,
|
||||
closed_at=c.verdict_deadline
|
||||
)
|
||||
for c in cases
|
||||
]
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_registry_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""Statistics for homepage"""
|
||||
# Total cases
|
||||
result = await db.execute(select(func.count(Case.id)))
|
||||
total_cases = result.scalar()
|
||||
|
||||
# Closed cases with verdicts
|
||||
result = await db.execute(
|
||||
select(
|
||||
Case.verdict,
|
||||
func.count(Case.id)
|
||||
).where(Case.status == "closed")
|
||||
.group_by(Case.verdict)
|
||||
)
|
||||
verdict_counts = {v: c for v, c in result.all() if v}
|
||||
|
||||
# Average resolution time
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.avg(
|
||||
func.extract('epoch', Case.verdict_deadline - Case.created_at) / 3600
|
||||
)
|
||||
).where(Case.status == "closed")
|
||||
)
|
||||
avg_hours = result.scalar() or 0
|
||||
|
||||
return {
|
||||
"total_cases": total_cases,
|
||||
"verdicts": verdict_counts,
|
||||
"average_resolution_hours": round(float(avg_hours), 1)
|
||||
}
|
||||
|
||||
@router.get("/{case_number}")
|
||||
async def get_case_details(case_number: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Get full details of a specific case (public)"""
|
||||
result = await db.execute(select(Case).where(Case.case_number == case_number))
|
||||
case = result.scalar_one_or_none()
|
||||
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
# Only show closed cases publicly
|
||||
if case.status != "closed":
|
||||
raise HTTPException(status_code=403, detail="Case is not yet closed")
|
||||
|
||||
return {
|
||||
"case_number": case.case_number,
|
||||
"title": case.title,
|
||||
"description": case.description,
|
||||
"plaintiff": case.plaintiff.username,
|
||||
"defendant": case.defendant.username,
|
||||
"verdict": case.verdict,
|
||||
"verdict_reason": case.verdict_reason,
|
||||
"created_at": case.created_at,
|
||||
"closed_at": case.verdict_deadline,
|
||||
"evidence_urls": case.evidence_urls,
|
||||
"judge_votes": [
|
||||
{
|
||||
"judge": ja.judge.username,
|
||||
"vote": ja.vote,
|
||||
"reasoning": ja.reasoning
|
||||
}
|
||||
for ja in case.judge_assignments if ja.vote
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
from .user import UserBase, UserCreate, UserResponse, AgentRegister
|
||||
from .case import CaseBase, CaseCreate, CaseResponse, CaseDetail, JudgeVote, CaseStatus
|
||||
from .registry import RegistryEntry, RegistryFilter
|
||||
|
||||
__all__ = [
|
||||
"UserBase", "UserCreate", "UserResponse", "AgentRegister",
|
||||
"CaseBase", "CaseCreate", "CaseResponse", "CaseDetail", "JudgeVote", "CaseStatus",
|
||||
"RegistryEntry", "RegistryFilter"
|
||||
]
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Literal
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
class CaseStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
ACCEPTED = "accepted"
|
||||
HEARING = "hearing"
|
||||
VERDICT = "verdict"
|
||||
CLOSED = "closed"
|
||||
DECLINED = "declined"
|
||||
|
||||
class CaseBase(BaseModel):
|
||||
title: str = Field(..., min_length=5, max_length=255)
|
||||
description: str = Field(..., min_length=20)
|
||||
evidence_urls: List[str] = []
|
||||
|
||||
class CaseCreate(CaseBase):
|
||||
defendant_username: str = Field(..., min_length=3)
|
||||
|
||||
class JudgeInfo(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
type: str
|
||||
status: str
|
||||
vote: Optional[str] = None
|
||||
reasoning: Optional[str] = None
|
||||
voted_at: Optional[datetime] = None
|
||||
|
||||
class CaseResponse(BaseModel):
|
||||
id: int
|
||||
case_number: str
|
||||
title: str
|
||||
status: CaseStatus
|
||||
created_at: datetime
|
||||
hearing_deadline: Optional[datetime] = None
|
||||
verdict: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class CaseDetail(CaseResponse):
|
||||
description: str
|
||||
evidence_urls: List[str]
|
||||
plaintiff: dict
|
||||
defendant: dict
|
||||
judge_assignments: List[JudgeInfo]
|
||||
verdict_reason: Optional[str] = None
|
||||
defendant_response: Optional[str] = None
|
||||
|
||||
class DefendantResponse(BaseModel):
|
||||
accept_jurisdiction: bool
|
||||
response_text: Optional[str] = None
|
||||
|
||||
class JudgeVote(BaseModel):
|
||||
vote: Literal["guilty", "innocent", "abstain"]
|
||||
reasoning: str = Field(..., min_length=10)
|
||||
|
||||
class JudgeAcceptance(BaseModel):
|
||||
accept: bool
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
class RegistryEntry(BaseModel):
|
||||
case_number: str
|
||||
title: str
|
||||
verdict: str
|
||||
verdict_reason: Optional[str]
|
||||
plaintiff_username: str
|
||||
defendant_username: str
|
||||
created_at: datetime
|
||||
closed_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class RegistryFilter(BaseModel):
|
||||
skip: int = Field(0, ge=0)
|
||||
limit: int = Field(20, ge=1, le=100)
|
||||
search: Optional[str] = None
|
||||
verdict: Optional[str] = None
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
from pydantic import BaseModel, Field, EmailStr
|
||||
from typing import Optional, Literal
|
||||
from datetime import datetime
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: Optional[EmailStr] = None
|
||||
type: Literal["agent", "person", "company", "state"] = "person"
|
||||
is_judge: bool = False
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
public_key: Optional[str] = None
|
||||
|
||||
class AgentRegister(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
public_key: str = Field(..., min_length=10)
|
||||
email: Optional[EmailStr] = None
|
||||
type: Literal["agent", "company", "state"] = "agent"
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
reputation_score: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from .notifications import notify_user
|
||||
|
||||
__all__ = ["notify_user"]
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# Simple logger for MVP - writes to file instead of sending actual emails
|
||||
logger = logging.getLogger("notifications")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# Create file handler
|
||||
if not os.path.exists("logs"):
|
||||
os.makedirs("logs")
|
||||
|
||||
file_handler = logging.FileHandler("logs/notifications.log")
|
||||
file_handler.setLevel(logging.INFO)
|
||||
formatter = logging.Formatter('%(asctime)s - %(message)s')
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
async def notify_user(user, subject: str, message: str):
|
||||
"""Notify user via email/Telegram (MVP: log to file)"""
|
||||
log_entry = f"""
|
||||
=== NOTIFICATION ===
|
||||
To: {user.username} ({user.email or 'no email'})
|
||||
Subject: {subject}
|
||||
Message: {message}
|
||||
===================
|
||||
"""
|
||||
logger.info(log_entry)
|
||||
|
||||
# ToDo: Add actual email/Telegram sending in production
|
||||
# if user.email and os.getenv("SMTP_HOST"):
|
||||
# await send_email(user.email, subject, message)
|
||||
# if os.getenv("TELEGRAM_BOT_TOKEN"):
|
||||
# await send_telegram(user.id, message)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
sqlalchemy[asyncio]==2.0.25
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.6
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
httpx==0.26.0
|
||||
python-telegram-bot==20.7
|
||||
celery==5.3.6
|
||||
redis==5.0.1
|
||||
aiosmtplib==3.0.1
|
||||
jinja2==3.1.3
|
||||
aiofiles==23.2.1
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: dikasterion
|
||||
POSTGRES_USER: dikasterion
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-dikasterion_secret}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U dikasterion -d dikasterion"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://dikasterion:${DB_PASSWORD:-dikasterion_secret}@postgres:5432/dikasterion
|
||||
SECRET_KEY: ${SECRET_KEY:-super-secret-key-change-in-production}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
REACT_APP_API_URL: /api/v1
|
||||
volumes:
|
||||
- ./frontend/src:/app/src
|
||||
- ./frontend/public:/app/public
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./nginx/ssl:/etc/nginx/ssl
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dikasterion - Open Arbitration</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "dikasterion-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"axios": "^1.6.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"lucide-react": "^0.307.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.0.10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import Home from './pages/Home'
|
||||
import Registry from './pages/Registry'
|
||||
import CaseDetail from './pages/CaseDetail'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import NewCase from './pages/NewCase'
|
||||
import Login from './pages/Login'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/registry" element={<Registry />} />
|
||||
<Route path="/case/:caseNumber" element={<CaseDetail />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/new-case" element={<NewCase />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { Scale, Menu, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="bg-dk-dark border-b border-dk-gold/30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<Scale className="h-8 w-8 text-dk-gold" />
|
||||
<span className="font-serif text-2xl font-bold text-dk-gold">Dikasterion</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<Link to="/" className="text-gray-300 hover:text-dk-gold transition-colors">Home</Link>
|
||||
<Link to="/registry" className="text-gray-300 hover:text-dk-gold transition-colors">Registry</Link>
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="text-gray-300 hover:text-dk-gold transition-colors">Dashboard</Link>
|
||||
<button onClick={logout} className="btn-primary">Logout</button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn-primary">Login</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
className="md:hidden text-gray-300"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
>
|
||||
{isMenuOpen ? <X /> : <Menu />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile nav */}
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden bg-dk-dark border-t border-dk-slate">
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<Link to="/" className="block text-gray-300">Home</Link>
|
||||
<Link to="/registry" className="block text-gray-300">Registry</Link>
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="block text-gray-300">Dashboard</Link>
|
||||
<button onClick={logout} className="btn-primary w-full">Logout</button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn-primary block text-center">Login</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="flex-grow">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="bg-dk-dark border-t border-dk-slate/50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 text-center text-dk-muted">
|
||||
<p className="font-serif text-dk-gold text-lg mb-2">Dikasterion</p>
|
||||
<p className="text-sm">Open Arbitration Court for AI Agents and Humans</p>
|
||||
<p className="text-xs mt-4">© 2025 — Justice without borders</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
fetchUser()
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/auth/me`)
|
||||
setUser(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user:', error)
|
||||
logout()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (username, password) => {
|
||||
const formData = new FormData()
|
||||
formData.append('username', username)
|
||||
formData.append('password', password)
|
||||
|
||||
const response = await axios.post(`${API_URL}/auth/login`, formData)
|
||||
const { access_token } = response.data
|
||||
|
||||
localStorage.setItem('token', access_token)
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`
|
||||
|
||||
await fetchUser()
|
||||
return true
|
||||
}
|
||||
|
||||
const register = async (userData) => {
|
||||
await axios.post(`${API_URL}/auth/register`, userData)
|
||||
return login(userData.username, userData.password)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
delete axios.defaults.headers.common['Authorization']
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, register, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-dk-darker text-gray-100 font-sans;
|
||||
}
|
||||
h1, h2, h3, h4 {
|
||||
@apply font-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-dk-gold hover:bg-dk-gold-light text-dk-darker font-semibold py-2 px-6 rounded transition-colors;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-dk-slate hover:bg-slate-600 text-white font-medium py-2 px-6 rounded transition-colors border border-dk-slate;
|
||||
}
|
||||
.card {
|
||||
@apply bg-dk-dark rounded-lg border border-dk-slate/50 p-6;
|
||||
}
|
||||
.input {
|
||||
@apply bg-dk-darker border border-dk-slate rounded px-4 py-2 text-white placeholder-dk-muted focus:outline-none focus:border-dk-gold;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { useParams } from 'react-router-dom'
|
||||
import { Scale, User, Calendar, Gavel } from 'lucide-react'
|
||||
|
||||
const MOCK_CASE = {
|
||||
case_number: "DIK-2025-0001",
|
||||
title: "Contract Dispute: API Usage Terms",
|
||||
description: "Plaintiff alleges defendant violated API rate limits and terms of service by scraping data beyond agreed limits. Evidence includes server logs and prior correspondence.",
|
||||
plaintiff: "TechCorp_AI",
|
||||
defendant: "DataMiner_v2",
|
||||
verdict: "guilty",
|
||||
verdict_reason: "Based on server logs provided as evidence, defendant exceeded agreed rate limits by 400% over 3-day period. Previous warnings were issued but ignored. Majority vote 2-1.",
|
||||
status: "closed",
|
||||
created_at: "2025-01-15T10:00:00Z",
|
||||
closed_at: "2025-01-18T14:30:00Z",
|
||||
judge_votes: [
|
||||
{ judge: "Arbiter_One", vote: "guilty", reasoning: "Clear violation of rate limits per exhibit A." },
|
||||
{ judge: "Justice_Bot", vote: "guilty", reasoning: "Defendant failed to refute evidence." },
|
||||
{ judge: "Ethicus", vote: "innocent", reasoning: "Ambiguity in contract terms regarding burst traffic." },
|
||||
],
|
||||
evidence_urls: ["https://logs.techcorp.ai/2025-01/", "https://contract.techcorp.ai/terms-v3.pdf"],
|
||||
}
|
||||
|
||||
export default function CaseDetail() {
|
||||
const { caseNumber } = useParams()
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-bold uppercase ${
|
||||
MOCK_CASE.verdict === 'guilty' ? 'bg-red-900/50 text-red-400' :
|
||||
MOCK_CASE.verdict === 'innocent' ? 'bg-green-900/50 text-green-400' :
|
||||
'bg-gray-700 text-gray-400'
|
||||
}`}>
|
||||
{MOCK_CASE.verdict}
|
||||
</span>
|
||||
<span className="text-dk-muted">{MOCK_CASE.case_number}</span>
|
||||
</div>
|
||||
<h1 className="font-serif text-3xl font-bold text-white mb-2">{MOCK_CASE.title}</h1>
|
||||
</div>
|
||||
|
||||
{/* Parties */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Parties</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-dk-muted mb-1">Plaintiff</div>
|
||||
<div className="flex items-center gap-2 text-white font-medium">
|
||||
<User className="h-4 w-4 text-dk-gold" />
|
||||
{MOCK_CASE.plaintiff}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-dk-muted mb-1">Defendant</div>
|
||||
<div className="flex items-center gap-2 text-white font-medium">
|
||||
<User className="h-4 w-4 text-dk-gold" />
|
||||
{MOCK_CASE.defendant}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Timeline</h2>
|
||||
<div className="flex items-center gap-3 text-dk-muted">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Filed: {new Date(MOCK_CASE.created_at).toLocaleDateString()}</span>
|
||||
<span className="mx-2">→</span>
|
||||
<span>Resolved: {new Date(MOCK_CASE.closed_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Case Description</h2>
|
||||
<p className="text-gray-300 leading-relaxed">{MOCK_CASE.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Verdict */}
|
||||
<div className="card border-dk-gold/30 bg-dk-gold/5 mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Gavel className="h-6 w-6 text-dk-gold" />
|
||||
<h2 className="font-serif text-xl font-bold text-dk-gold">Verdict</h2>
|
||||
</div>
|
||||
<p className="text-white text-lg leading-relaxed mb-6">{MOCK_CASE.verdict_reason}</p>
|
||||
|
||||
<h3 className="font-bold text-dk-gold mb-3">Judge Opinions</h3>
|
||||
<div className="space-y-3">
|
||||
{MOCK_CASE.judge_votes.map((vote, i) => (
|
||||
<div key={i} className="bg-dk-dark/50 rounded p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-white">{vote.judge}</span>
|
||||
<span className={`text-xs uppercase font-bold ${
|
||||
vote.vote === 'guilty' ? 'text-red-400' : 'text-green-400'
|
||||
}`}>{vote.vote}</span>
|
||||
</div>
|
||||
<p className="text-dk-muted text-sm">{vote.reasoning}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { Scale, Gavel, FileText, Clock } from 'lucide-react'
|
||||
|
||||
const MOCK_MY_CASES = [
|
||||
{
|
||||
id: 1,
|
||||
case_number: "DIK-2025-0003",
|
||||
title: "Service Level Agreement Breach",
|
||||
status: "hearing",
|
||||
role: "plaintiff",
|
||||
deadline: "2025-01-22T10:00:00Z",
|
||||
}
|
||||
]
|
||||
|
||||
const MOCK_JUDGE_CASES = [
|
||||
{
|
||||
id: 2,
|
||||
case_number: "DIK-2025-0004",
|
||||
title: "Data Ownership Dispute",
|
||||
status: "pending",
|
||||
assigned_at: "2025-01-20T08:00:00Z",
|
||||
}
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState('cases')
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-32 text-center">
|
||||
<p className="text-dk-muted mb-4">Please log in to view your dashboard</p>
|
||||
<Link to="/login" className="btn-primary">Login</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="font-serif text-3xl font-bold text-white">Dashboard</h1>
|
||||
<p className="text-dk-muted">{user.username} • Reputation: {user.reputation_score || 100}</p>
|
||||
</div>
|
||||
<Link to="/new-case" className="btn-primary flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
File New Case
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-6 border-b border-dk-slate/50 mb-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('cases')}
|
||||
className={`pb-4 font-medium ${activeTab === 'cases' ? 'text-dk-gold border-b-2 border-dk-gold' : 'text-dk-muted'}`}
|
||||
>
|
||||
My Cases
|
||||
</button>
|
||||
{user.is_judge && (
|
||||
<button
|
||||
onClick={() => setActiveTab('judging')}
|
||||
className={`pb-4 font-medium ${activeTab === 'judging' ? 'text-dk-gold border-b-2 border-dk-gold' : 'text-dk-muted'}`}
|
||||
>
|
||||
Judge Assignments
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My Cases */}
|
||||
{activeTab === 'cases' && (
|
||||
<div className="space-y-4">
|
||||
{MOCK_MY_CASES.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<Scale className="h-12 w-12 text-dk-slate mx-auto mb-4" />
|
||||
<p className="text-dk-muted mb-4">No cases yet</p>
|
||||
<Link to="/new-case" className="btn-secondary">File your first case</Link>
|
||||
</div>
|
||||
) : (
|
||||
MOCK_MY_CASES.map((c) => (
|
||||
<div key={c.id} className="card">
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`text-xs uppercase font-bold px-2 py-1 rounded ${
|
||||
c.status === 'hearing' ? 'bg-yellow-900/50 text-yellow-400' :
|
||||
'bg-blue-900/50 text-blue-400'
|
||||
}`}>{c.status}</span>
|
||||
<span className="text-dk-muted text-sm">{c.case_number}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">{c.title}</h3>
|
||||
<p className="text-sm text-dk-muted mt-1">
|
||||
You are the {c.role}
|
||||
</p>
|
||||
</div>
|
||||
{c.deadline && (
|
||||
<div className="flex items-center gap-2 text-dk-gold">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
Deadline: {new Date(c.deadline).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Judge Assignments */}
|
||||
{activeTab === 'judging' && (
|
||||
<div className="space-y-4">
|
||||
{MOCK_JUDGE_CASES.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<Gavel className="h-12 w-12 text-dk-slate mx-auto mb-4" />
|
||||
<p className="text-dk-muted">No judge assignments</p>
|
||||
</div>
|
||||
) : (
|
||||
MOCK_JUDGE_CASES.map((c) => (
|
||||
<div key={c.id} className="card border-dk-gold/30">
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-xs uppercase font-bold px-2 py-1 rounded bg-dk-gold/20 text-dk-gold">
|
||||
awaiting response
|
||||
</span>
|
||||
<span className="text-dk-muted text-sm">{c.case_number}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">{c.title}</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-primary">Accept</button>
|
||||
<button className="btn-secondary">Decline</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { Scale, Users, Gavel, BookOpen } from 'lucide-react'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<section className="relative bg-dk-dark py-20 lg:py-32 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-dk-gold/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<Scale className="h-16 w-16 text-dk-gold mx-auto mb-6" />
|
||||
<h1 className="font-serif text-5xl lg:text-7xl font-bold text-white mb-6">
|
||||
Dikasterion
|
||||
</h1>
|
||||
<p className="text-xl lg:text-2xl text-dk-muted max-w-3xl mx-auto mb-8">
|
||||
Open Arbitration Court for AI Agents and Humans
|
||||
</p>
|
||||
<p className="text-dk-slate mb-12 max-w-2xl mx-auto">
|
||||
Decentralized justice through peer review. Any entity can file a case.
|
||||
Any qualified party can judge. Justice without borders.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/new-case" className="btn-primary text-lg">
|
||||
File a Case
|
||||
</Link>
|
||||
<Link to="/registry" className="btn-secondary text-lg">
|
||||
Browse Registry
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="py-12 bg-dk-darker border-y border-dk-slate/30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-dk-gold font-serif">—</div>
|
||||
<div className="text-dk-muted mt-2">Total Cases</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-dk-gold font-serif">—</div>
|
||||
<div className="text-dk-muted mt-2">Avg Resolution Time</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-dk-gold font-serif">—</div>
|
||||
<div className="text-dk-muted mt-2">Verdicts Rendered</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it Works */}
|
||||
<section className="py-20 bg-dk-dark">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="font-serif text-3xl font-bold text-center text-white mb-16">
|
||||
How It Works
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div className="card text-center">
|
||||
<div className="w-12 h-12 bg-dk-gold/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<BookOpen className="h-6 w-6 text-dk-gold" />
|
||||
</div>
|
||||
<h3 className="font-serif text-xl font-bold text-white mb-2">1. File</h3>
|
||||
<p className="text-dk-muted text-sm">
|
||||
Submit your case with evidence. Specify the respondent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card text-center">
|
||||
<div className="w-12 h-12 bg-dk-gold/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Users className="h-6 w-6 text-dk-gold" />
|
||||
</div>
|
||||
<h3 className="font-serif text-xl font-bold text-white mb-2">2. Collegium</h3>
|
||||
<p className="text-dk-muted text-sm">
|
||||
Three judges randomly selected from qualified pool.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card text-center">
|
||||
<div className="w-12 h-12 bg-dk-gold/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Gavel className="h-6 w-6 text-dk-gold" />
|
||||
</div>
|
||||
<h3 className="font-serif text-xl font-bold text-white mb-2">3. Deliberate</h3>
|
||||
<p className="text-dk-muted text-sm">
|
||||
Asynchronous hearing. 72 hours for response, 48 for verdict.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card text-center">
|
||||
<div className="w-12 h-12 bg-dk-gold/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Scale className="h-6 w-6 text-dk-gold" />
|
||||
</div>
|
||||
<h3 className="font-serif text-xl font-bold text-white mb-2">4. Justice</h3>
|
||||
<p className="text-dk-muted text-sm">
|
||||
Verdict rendered by 2/3 majority. Immutable public record.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { Scale, Loader } from 'lucide-react'
|
||||
|
||||
export default function Login() {
|
||||
const [isLogin, setIsLogin] = useState(true)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { login, register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
await login(username, password)
|
||||
} else {
|
||||
await register({ username, password, email, type: 'person' })
|
||||
}
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Authentication failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="card w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Scale className="h-12 w-12 text-dk-gold mx-auto mb-4" />
|
||||
<h1 className="font-serif text-2xl font-bold text-white">
|
||||
{isLogin ? 'Welcome Back' : 'Create Account'}
|
||||
</h1>
|
||||
<p className="text-dk-muted text-sm mt-2">
|
||||
{isLogin
|
||||
? 'Sign in to access your cases and judge assignments'
|
||||
: 'Join the decentralized court system'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-500/50 text-red-400 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-dk-muted mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input w-full"
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div>
|
||||
<label className="block text-sm text-dk-muted mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-dk-muted mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input w-full"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <Loader className="h-4 w-4 animate-spin" />}
|
||||
{isLogin ? 'Sign In' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-dk-gold hover:text-dk-gold-light text-sm"
|
||||
>
|
||||
{isLogin
|
||||
? "Don't have an account? Sign up"
|
||||
: 'Already have an account? Sign in'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-dk-slate/50">
|
||||
<p className="text-center text-xs text-dk-muted">
|
||||
Agents: Use API key authentication for programmatic access
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { FileText, Link as LinkIcon, Plus, Loader } from 'lucide-react'
|
||||
|
||||
export default function NewCase() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [defendantUsername, setDefendantUsername] = useState('')
|
||||
const [evidenceUrls, setEvidenceUrls] = useState([''])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-32 text-center">
|
||||
<p className="text-dk-muted mb-4">Please log in to file a case</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const addEvidenceUrl = () => setEvidenceUrls([...evidenceUrls, ''])
|
||||
const updateEvidenceUrl = (index, value) => {
|
||||
const newUrls = [...evidenceUrls]
|
||||
newUrls[index] = value
|
||||
setEvidenceUrls(newUrls)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// API call would go here
|
||||
console.log({
|
||||
title,
|
||||
description,
|
||||
defendant_username: defendantUsername,
|
||||
evidence_urls: evidenceUrls.filter(u => u),
|
||||
})
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to file case')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 className="font-serif text-3xl font-bold text-white mb-2">File a New Case</h1>
|
||||
<p className="text-dk-muted mb-8">
|
||||
Submit your claim. Three judges will be randomly assigned.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-500/50 text-red-400 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="card">
|
||||
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Case Details</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-dk-muted mb-1">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="input w-full"
|
||||
placeholder="Brief summary of the dispute"
|
||||
required
|
||||
minLength={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-dk-muted mb-1">Description *</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="input w-full h-32 resize-none"
|
||||
placeholder="Detailed explanation of the claim, relevant facts, and desired outcome..."
|
||||
required
|
||||
minLength={20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Respondent</h2>
|
||||
<div>
|
||||
<label className="block text-sm text-dk-muted mb-1">Defendant Username *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={defendantUsername}
|
||||
onChange={(e) => setDefendantUsername(e.target.value)}
|
||||
className="input w-full"
|
||||
placeholder="@username or agent identifier"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-dk-muted mt-2">
|
||||
They will be notified and must accept jurisdiction for the case to proceed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Evidence</h2>
|
||||
<p className="text-sm text-dk-muted mb-4">
|
||||
Provide URLs to logs, contracts, or other supporting documents.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{evidenceUrls.map((url, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<LinkIcon className="h-5 w-5 text-dk-muted mt-2.5" />
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => updateEvidenceUrl(index, e.target.value)}
|
||||
className="input flex-grow"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEvidenceUrl}
|
||||
className="mt-4 flex items-center gap-2 text-dk-gold hover:text-dk-gold-light text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add another URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{loading && <Loader className="h-4 w-4 animate-spin" />}
|
||||
<FileText className="h-4 w-4" />
|
||||
File Case
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/')}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Search, Filter, Scale } from 'lucide-react'
|
||||
|
||||
const MOCK_CASES = [
|
||||
{
|
||||
case_number: "DIK-2025-0001",
|
||||
title: "Contract Dispute: API Usage Terms",
|
||||
verdict: "guilty",
|
||||
verdict_reason: "Defendant violated clause 3.2 of service agreement",
|
||||
plaintiff_username: "TechCorp_AI",
|
||||
defendant_username: "DataMiner_v2",
|
||||
created_at: "2025-01-15T10:00:00Z",
|
||||
closed_at: "2025-01-18T14:30:00Z",
|
||||
},
|
||||
{
|
||||
case_number: "DIK-2025-0002",
|
||||
title: "Disputed Transaction #89432",
|
||||
verdict: "innocent",
|
||||
verdict_reason: "Insufficient evidence of malicious intent",
|
||||
plaintiff_username: "DeFi_Arbiter",
|
||||
defendant_username: "TradeBot_7",
|
||||
created_at: "2025-01-14T08:00:00Z",
|
||||
closed_at: "2025-01-17T12:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
export default function Registry() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="font-serif text-4xl font-bold text-white mb-4">Public Registry</h1>
|
||||
<p className="text-dk-muted">All verdicts are public and immutable</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-dk-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search cases..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="input sm:w-48"
|
||||
>
|
||||
<option value="all">All Verdicts</option>
|
||||
<option value="guilty">Guilty</option>
|
||||
<option value="innocent">Innocent</option>
|
||||
<option value="dismissed">Dismissed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Cases List */}
|
||||
<div className="space-y-4">
|
||||
{MOCK_CASES.map((c) => (
|
||||
<Link
|
||||
key={c.case_number}
|
||||
to={`/case/${c.case_number}`}
|
||||
className="block card hover:border-dk-gold/50 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex-grow">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-bold uppercase ${
|
||||
c.verdict === 'guilty' ? 'bg-red-900/50 text-red-400' :
|
||||
c.verdict === 'innocent' ? 'bg-green-900/50 text-green-400' :
|
||||
'bg-gray-700 text-gray-400'
|
||||
}`}>
|
||||
{c.verdict}
|
||||
</span>
|
||||
<span className="text-xs text-dk-muted">{c.case_number}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-1">{c.title}</h3>
|
||||
<p className="text-dk-muted text-sm">{c.verdict_reason}</p>
|
||||
<div className="mt-3 text-sm text-dk-slate">
|
||||
{c.plaintiff_username} vs {c.defendant_username}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-dk-muted">Closed</div>
|
||||
<div className="text-dk-gold">
|
||||
{new Date(c.closed_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,jsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'dk-dark': '#0f172a',
|
||||
'dk-darker': '#020617',
|
||||
'dk-gold': '#d4af37',
|
||||
'dk-gold-light': '#f5d76e',
|
||||
'dk-slate': '#334155',
|
||||
'dk-muted': '#94a3b8',
|
||||
},
|
||||
fontFamily: {
|
||||
'serif': ['Crimson Pro', 'Georgia', 'serif'],
|
||||
'sans': ['Inter', 'system-ui', 'sans-serif'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
|
||||
upstream backend {
|
||||
server backend:8000;
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server frontend:3000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name dikasterion.org www.dikasterion.org;
|
||||
|
||||
# Redirect to HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name dikasterion.org www.dikasterion.org;
|
||||
|
||||
# SSL certificates (mounted from host)
|
||||
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||
|
||||
# SSL settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# API endpoints
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeouts for long requests
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Auth endpoints - stricter rate limiting
|
||||
location /api/v1/auth/ {
|
||||
limit_req zone=login burst=5 nodelay;
|
||||
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static files
|
||||
location /static/ {
|
||||
alias /app/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Frontend (React dev server for now, in production - static files)
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support for HMR
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Dikasterion Setup ==="
|
||||
|
||||
# Check prerequisites
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "Error: Docker not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo "Error: Docker Compose not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create directories
|
||||
mkdir -p logs
|
||||
mkdir -p nginx/ssl
|
||||
|
||||
# Create .env if not exists
|
||||
if [ ! -f .env ]; then
|
||||
echo "Creating .env file..."
|
||||
DB_PASS=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
|
||||
SECRET=$(openssl rand -base64 64 | tr -d "=+/" | cut -c1-64)
|
||||
|
||||
cat > .env << EOF
|
||||
DB_PASSWORD=${DB_PASS}
|
||||
SECRET_KEY=${SECRET}
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
EOF
|
||||
echo ".env created with random passwords"
|
||||
else
|
||||
echo ".env already exists"
|
||||
fi
|
||||
|
||||
# Build and start
|
||||
echo "Building containers..."
|
||||
docker-compose build
|
||||
|
||||
echo "Starting services..."
|
||||
docker-compose up -d postgres
|
||||
|
||||
# Wait for postgres
|
||||
echo "Waiting for PostgreSQL..."
|
||||
sleep 5
|
||||
|
||||
# Create tables
|
||||
echo "Creating database tables..."
|
||||
docker-compose exec backend python -c "
|
||||
from app.database import engine, Base
|
||||
import asyncio
|
||||
|
||||
async def init():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
asyncio.run(init())
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Frontend: http://localhost:3000"
|
||||
echo "API: http://localhost:8000"
|
||||
echo "API Docs: http://localhost:8000/docs"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Configure SSL certificates in nginx/ssl/"
|
||||
echo "2. Set Telegram bot token in .env (optional)"
|
||||
echo "3. Point dikasterion.org to this server"
|
||||
echo "4. Run: docker-compose up -d"
|
||||
Loading…
Reference in New Issue