Skip to content
68 changes: 68 additions & 0 deletions .claude/skills/project-conventions/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
name: project-conventions
description: Enforces Temis-RiskControl project conventions for backend architecture, file placement, naming, and stack usage. Invoke before adding features, new files, or modifying existing layers.
---

# Temis-RiskControl — Project Conventions

When working in this project, always follow the rules below. Do not deviate unless the user explicitly asks.

---

## Stack

- **Backend**: Python 3.12 + FastAPI + Uvicorn
- **Config**: Pydantic `BaseSettings` — never hardcode values, always read from environment
- **Frontend**: Not yet implemented — do not scaffold it unless asked
- **Agent**: Not yet implemented — do not scaffold it unless asked

---

## Backend Folder Structure

All backend code lives inside `backend/app/`. Respect the layered architecture:

| Folder | What belongs here |
|---|---|
| `api/` | FastAPI route handlers and the router aggregator (`router.py`) |
| `core/` | App-wide config, settings, startup logic |
| `database/` | DB connection setup, session factories |
| `entities/` | Domain/business entities (pure Python classes, no DB coupling) |
| `models/` | Pydantic request/response schemas |
| `repositories/` | Data access — all queries go here, never in routes |
| `infra/` | External service clients (email, storage, third-party APIs) |

### Rules

- Never put business logic directly in `api/` route handlers — delegate to a service or repository
- Never import from `api/` inside `repositories/`, `entities/`, or `models/` — dependencies flow downward only
- New endpoints must be registered in `backend/app/api/router.py`, not mounted directly in `main.py`
- All API routes must be prefixed with `/api/`

---

## Naming Conventions

- **Files**: `snake_case.py`
- **Classes**: `PascalCase`
- **Functions and variables**: `snake_case`
- **Pydantic models**: suffix with `Request`, `Response`, or `Schema` (e.g., `CreateUserRequest`, `UserResponse`)
- **Repository classes**: suffix with `Repository` (e.g., `UserRepository`)
- **Entity classes**: no suffix, plain domain name (e.g., `User`, `RiskEvent`)

---

## Environment Variables

- Never hardcode secrets or config values
- All variables must have a corresponding entry in `backend/.env.example`
- Read them through the Pydantic `Settings` class in `backend/app/core/config.py`


## What NOT to do

- Do not create files outside their designated layer folder
- Do not add dependencies to `requirements.txt` without a clear reason — ask the user first
- Do not implement frontend or agent features unless explicitly asked
- Do not skip `.env.example` updates when adding new environment variables
- Do not add endpoints directly in `main.py` — always go through `router.py`
2 changes: 2 additions & 0 deletions backend/app/api/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from fastapi import APIRouter

from app.api.health import router as health_router
from app.api.usuarios import router as usuarios_router

api_router = APIRouter(prefix="/api")
api_router.include_router(health_router)
api_router.include_router(usuarios_router)
57 changes: 57 additions & 0 deletions backend/app/api/usuarios.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import uuid

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.database.session import get_db
from app.models.usuario import CreateUsuarioRequest, UpdateUsuarioRequest, UsuarioResponse
from app.repositories.usuario_repository import UsuarioRepository

router = APIRouter(prefix="/usuarios", tags=["usuarios"])


@router.post("", response_model=UsuarioResponse, status_code=status.HTTP_201_CREATED)
async def create_usuario(body: CreateUsuarioRequest, db: AsyncSession = Depends(get_db)) -> UsuarioResponse:
repo = UsuarioRepository(db)
try:
return await repo.create(email=body.email, telefono=body.telefono, status=body.status)
except IntegrityError:
raise HTTPException(status_code=409, detail="Email already registered")


@router.get("", response_model=list[UsuarioResponse])
async def list_usuarios(db: AsyncSession = Depends(get_db)) -> list[UsuarioResponse]:
repo = UsuarioRepository(db)
return await repo.get_all()


@router.get("/{usuario_id}", response_model=UsuarioResponse)
async def get_usuario(usuario_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> UsuarioResponse:
repo = UsuarioRepository(db)
usuario = await repo.get_by_id(usuario_id)
if not usuario:
raise HTTPException(status_code=404, detail="Usuario not found")
return usuario


@router.patch("/{usuario_id}", response_model=UsuarioResponse)
async def update_usuario(
usuario_id: uuid.UUID,
body: UpdateUsuarioRequest,
db: AsyncSession = Depends(get_db),
) -> UsuarioResponse:
repo = UsuarioRepository(db)
usuario = await repo.get_by_id(usuario_id)
if not usuario:
raise HTTPException(status_code=404, detail="Usuario not found")
return await repo.update(usuario, email=body.email, telefono=body.telefono, status=body.status)


@router.delete("/{usuario_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_usuario(usuario_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> None:
repo = UsuarioRepository(db)
usuario = await repo.get_by_id(usuario_id)
if not usuario:
raise HTTPException(status_code=404, detail="Usuario not found")
await repo.delete(usuario)
5 changes: 5 additions & 0 deletions backend/app/database/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
pass
14 changes: 14 additions & 0 deletions backend/app/database/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from collections.abc import AsyncGenerator

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from app.core.config import settings

engine = create_async_engine(settings.database_url, echo=settings.debug)

AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)


async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
18 changes: 18 additions & 0 deletions backend/app/entities/usuario.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import uuid
from datetime import UTC, datetime

from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column

from app.database.base import Base


class Usuario(Base):
__tablename__ = "usuario"

id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
telefono: Mapped[str | None] = mapped_column(String, nullable=True)
status: Mapped[str] = mapped_column(String, default="active")
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
27 changes: 27 additions & 0 deletions backend/app/models/usuario.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import uuid
from datetime import datetime

from pydantic import BaseModel, EmailStr


class CreateUsuarioRequest(BaseModel):
email: EmailStr
telefono: str | None = None
status: str = "active"


class UpdateUsuarioRequest(BaseModel):
email: EmailStr | None = None
telefono: str | None = None
status: str | None = None


class UsuarioResponse(BaseModel):
id: uuid.UUID
email: str
telefono: str | None
status: str
last_login: datetime | None
created_at: datetime

model_config = {"from_attributes": True}
38 changes: 38 additions & 0 deletions backend/app/repositories/usuario_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import uuid

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.entities.usuario import Usuario


class UsuarioRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db

async def create(self, email: str, telefono: str | None, status: str) -> Usuario:
usuario = Usuario(email=email, telefono=telefono, status=status)
self.db.add(usuario)
await self.db.commit()
await self.db.refresh(usuario)
return usuario

async def get_by_id(self, usuario_id: uuid.UUID) -> Usuario | None:
result = await self.db.execute(select(Usuario).where(Usuario.id == usuario_id))
return result.scalar_one_or_none()

async def get_all(self) -> list[Usuario]:
result = await self.db.execute(select(Usuario))
return list(result.scalars().all())

async def update(self, usuario: Usuario, **fields: object) -> Usuario:
for key, value in fields.items():
if value is not None:
setattr(usuario, key, value)
await self.db.commit()
await self.db.refresh(usuario)
return usuario

async def delete(self, usuario: Usuario) -> None:
await self.db.delete(usuario)
await self.db.commit()
2 changes: 2 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto
Empty file added backend/tests/__init__.py
Empty file.
36 changes: 36 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from app.database.base import Base
from app.database.session import get_db
from app.main import app

TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"


@pytest_asyncio.fixture
async def db_session() -> AsyncSession:
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

AsyncTestSession = async_sessionmaker(engine, expire_on_commit=False)
async with AsyncTestSession() as session:
yield session

async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()


@pytest_asyncio.fixture
async def client(db_session: AsyncSession) -> AsyncClient:
async def override_get_db() -> AsyncSession:
yield db_session

app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
85 changes: 85 additions & 0 deletions backend/tests/test_usuarios.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import uuid

import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
async def test_create_usuario(client: AsyncClient) -> None:
response = await client.post("/api/usuarios", json={"email": "juan@example.com", "telefono": "555-1234"})
assert response.status_code == 201
data = response.json()
assert data["email"] == "juan@example.com"
assert data["telefono"] == "555-1234"
assert data["status"] == "active"
assert "id" in data
assert "created_at" in data


@pytest.mark.asyncio
async def test_create_usuario_duplicate_email(client: AsyncClient) -> None:
await client.post("/api/usuarios", json={"email": "dup@example.com"})
response = await client.post("/api/usuarios", json={"email": "dup@example.com"})
assert response.status_code == 409


@pytest.mark.asyncio
async def test_get_usuario(client: AsyncClient) -> None:
create_resp = await client.post("/api/usuarios", json={"email": "get@example.com"})
user_id = create_resp.json()["id"]

response = await client.get(f"/api/usuarios/{user_id}")
assert response.status_code == 200
assert response.json()["email"] == "get@example.com"


@pytest.mark.asyncio
async def test_get_usuario_not_found(client: AsyncClient) -> None:
response = await client.get(f"/api/usuarios/{uuid.uuid4()}")
assert response.status_code == 404


@pytest.mark.asyncio
async def test_list_usuarios(client: AsyncClient) -> None:
await client.post("/api/usuarios", json={"email": "a@example.com"})
await client.post("/api/usuarios", json={"email": "b@example.com"})

response = await client.get("/api/usuarios")
assert response.status_code == 200
assert len(response.json()) == 2


@pytest.mark.asyncio
async def test_update_usuario(client: AsyncClient) -> None:
create_resp = await client.post("/api/usuarios", json={"email": "upd@example.com", "telefono": "111"})
user_id = create_resp.json()["id"]

response = await client.patch(f"/api/usuarios/{user_id}", json={"status": "inactive", "telefono": "999"})
assert response.status_code == 200
data = response.json()
assert data["status"] == "inactive"
assert data["telefono"] == "999"


@pytest.mark.asyncio
async def test_update_usuario_not_found(client: AsyncClient) -> None:
response = await client.patch(f"/api/usuarios/{uuid.uuid4()}", json={"status": "inactive"})
assert response.status_code == 404


@pytest.mark.asyncio
async def test_delete_usuario(client: AsyncClient) -> None:
create_resp = await client.post("/api/usuarios", json={"email": "del@example.com"})
user_id = create_resp.json()["id"]

response = await client.delete(f"/api/usuarios/{user_id}")
assert response.status_code == 204

get_resp = await client.get(f"/api/usuarios/{user_id}")
assert get_resp.status_code == 404


@pytest.mark.asyncio
async def test_delete_usuario_not_found(client: AsyncClient) -> None:
response = await client.delete(f"/api/usuarios/{uuid.uuid4()}")
assert response.status_code == 404
Loading