From 8c686685df5913ac9b7114165cfbe4f7c72a4226 Mon Sep 17 00:00:00 2001 From: Ziye WANG Date: Sat, 21 Feb 2026 22:29:35 +0100 Subject: [PATCH 01/25] architect --- backend/app/api/routes/items.py | 92 +++++------- backend/app/api/routes/login.py | 12 +- backend/app/api/routes/private.py | 18 +-- backend/app/api/routes/users.py | 147 +++++++----------- backend/app/core/db.py | 13 +- backend/app/crud.py | 57 ++----- backend/app/repositories/__init__.py | 3 + backend/app/repositories/item_repository.py | 61 ++++++++ backend/app/repositories/user_repository.py | 44 ++++++ backend/app/services/__init__.py | 3 + backend/app/services/auth_service.py | 25 ++++ backend/app/services/exceptions.py | 22 +++ backend/app/services/item_service.py | 59 ++++++++ backend/app/services/user_service.py | 158 ++++++++++++++++++++ frontend/src/main.tsx | 12 +- frontend/src/routeTree.gen.ts | 6 +- 16 files changed, 502 insertions(+), 230 deletions(-) create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/item_repository.py create mode 100644 backend/app/repositories/user_repository.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/app/services/exceptions.py create mode 100644 backend/app/services/item_service.py create mode 100644 backend/app/services/user_service.py diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index f1929e5836..50f11cca33 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -1,15 +1,20 @@ import uuid -from typing import Any +from typing import Any, NoReturn from fastapi import APIRouter, HTTPException -from sqlmodel import col, func, select from app.api.deps import CurrentUser, SessionDep -from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message +from app.models import ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message +from app.services import item_service +from app.services.exceptions import ServiceError router = APIRouter(prefix="/items", tags=["items"]) +def _raise_http_from_service_error(exc: ServiceError) -> NoReturn: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) + + @router.get("/", response_model=ItemsPublic) def read_items( session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 @@ -17,30 +22,12 @@ def read_items( """ Retrieve items. """ - - if current_user.is_superuser: - count_statement = select(func.count()).select_from(Item) - count = session.exec(count_statement).one() - statement = ( - select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit) - ) - items = session.exec(statement).all() - else: - count_statement = ( - select(func.count()) - .select_from(Item) - .where(Item.owner_id == current_user.id) - ) - count = session.exec(count_statement).one() - statement = ( - select(Item) - .where(Item.owner_id == current_user.id) - .order_by(col(Item.created_at).desc()) - .offset(skip) - .limit(limit) - ) - items = session.exec(statement).all() - + items, count = item_service.list_items_for_user( + session=session, + current_user=current_user, + skip=skip, + limit=limit, + ) return ItemsPublic(data=items, count=count) @@ -49,12 +36,12 @@ def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> """ Get item by ID. """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=403, detail="Not enough permissions") - return item + try: + return item_service.get_item_for_user( + session=session, current_user=current_user, item_id=id + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) @router.post("/", response_model=ItemPublic) @@ -64,11 +51,9 @@ def create_item( """ Create new item. """ - item = Item.model_validate(item_in, update={"owner_id": current_user.id}) - session.add(item) - session.commit() - session.refresh(item) - return item + return item_service.create_item_for_user( + session=session, current_user=current_user, item_in=item_in + ) @router.put("/{id}", response_model=ItemPublic) @@ -82,17 +67,12 @@ def update_item( """ Update an item. """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=403, detail="Not enough permissions") - update_dict = item_in.model_dump(exclude_unset=True) - item.sqlmodel_update(update_dict) - session.add(item) - session.commit() - session.refresh(item) - return item + try: + return item_service.update_item_for_user( + session=session, current_user=current_user, item_id=id, item_in=item_in + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) @router.delete("/{id}") @@ -102,11 +82,11 @@ def delete_item( """ Delete an item. """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=403, detail="Not enough permissions") - session.delete(item) - session.commit() + try: + item_service.delete_item_for_user( + session=session, current_user=current_user, item_id=id + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) + return Message(message="Item deleted successfully") diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 58441e37e9..76bce366be 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -5,11 +5,11 @@ from fastapi.responses import HTMLResponse from fastapi.security import OAuth2PasswordRequestForm -from app import crud from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser from app.core import security from app.core.config import settings from app.models import Message, NewPassword, Token, UserPublic, UserUpdate +from app.services import auth_service, user_service from app.utils import ( generate_password_reset_token, generate_reset_password_email, @@ -27,7 +27,7 @@ def login_access_token( """ OAuth2 compatible token login, get an access token for future requests """ - user = crud.authenticate( + user = auth_service.authenticate( session=session, email=form_data.username, password=form_data.password ) if not user: @@ -55,7 +55,7 @@ def recover_password(email: str, session: SessionDep) -> Message: """ Password Recovery """ - user = crud.get_user_by_email(session=session, email=email) + user = user_service.get_user_by_email(session=session, email=email) # Always return the same response to prevent email enumeration attacks # Only send email if user actually exists @@ -82,14 +82,14 @@ def reset_password(session: SessionDep, body: NewPassword) -> Message: email = verify_password_reset_token(token=body.token) if not email: raise HTTPException(status_code=400, detail="Invalid token") - user = crud.get_user_by_email(session=session, email=email) + user = user_service.get_user_by_email(session=session, email=email) if not user: # Don't reveal that the user doesn't exist - use same error as invalid token raise HTTPException(status_code=400, detail="Invalid token") elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") user_in_update = UserUpdate(password=body.new_password) - crud.update_user( + user_service.update_user( session=session, db_user=user, user_in=user_in_update, @@ -106,7 +106,7 @@ def recover_password_html_content(email: str, session: SessionDep) -> Any: """ HTML Content for Password Recovery """ - user = crud.get_user_by_email(session=session, email=email) + user = user_service.get_user_by_email(session=session, email=email) if not user: raise HTTPException( diff --git a/backend/app/api/routes/private.py b/backend/app/api/routes/private.py index 9f33ef1900..9404d4f15b 100644 --- a/backend/app/api/routes/private.py +++ b/backend/app/api/routes/private.py @@ -4,11 +4,8 @@ from pydantic import BaseModel from app.api.deps import SessionDep -from app.core.security import get_password_hash -from app.models import ( - User, - UserPublic, -) +from app.models import UserPublic +from app.services import user_service router = APIRouter(tags=["private"], prefix="/private") @@ -25,14 +22,9 @@ def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any: """ Create a new user. """ - - user = User( + return user_service.create_private_user( + session=session, email=user_in.email, + password=user_in.password, full_name=user_in.full_name, - hashed_password=get_password_hash(user_in.password), ) - - session.add(user) - session.commit() - - return user diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 35f64b626e..705ce43df8 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,22 +1,17 @@ import uuid -from typing import Any +from typing import Any, NoReturn from fastapi import APIRouter, Depends, HTTPException -from sqlmodel import col, delete, func, select -from app import crud from app.api.deps import ( CurrentUser, SessionDep, get_current_active_superuser, ) from app.core.config import settings -from app.core.security import get_password_hash, verify_password from app.models import ( - Item, Message, UpdatePassword, - User, UserCreate, UserPublic, UserRegister, @@ -24,11 +19,17 @@ UserUpdate, UserUpdateMe, ) +from app.services import user_service +from app.services.exceptions import ServiceError from app.utils import generate_new_account_email, send_email router = APIRouter(prefix="/users", tags=["users"]) +def _raise_http_from_service_error(exc: ServiceError) -> NoReturn: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) + + @router.get( "/", dependencies=[Depends(get_current_active_superuser)], @@ -38,15 +39,7 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: """ Retrieve users. """ - - count_statement = select(func.count()).select_from(User) - count = session.exec(count_statement).one() - - statement = ( - select(User).order_by(col(User.created_at).desc()).offset(skip).limit(limit) - ) - users = session.exec(statement).all() - + users, count = user_service.list_users(session=session, skip=skip, limit=limit) return UsersPublic(data=users, count=count) @@ -57,14 +50,11 @@ def create_user(*, session: SessionDep, user_in: UserCreate) -> Any: """ Create new user. """ - user = crud.get_user_by_email(session=session, email=user_in.email) - if user: - raise HTTPException( - status_code=400, - detail="The user with this email already exists in the system.", - ) + try: + user = user_service.create_user_for_admin(session=session, user_in=user_in) + except ServiceError as exc: + _raise_http_from_service_error(exc) - user = crud.create_user(session=session, user_create=user_in) if settings.emails_enabled and user_in.email: email_data = generate_new_account_email( email_to=user_in.email, username=user_in.email, password=user_in.password @@ -84,19 +74,12 @@ def update_user_me( """ Update own user. """ - - if user_in.email: - existing_user = crud.get_user_by_email(session=session, email=user_in.email) - if existing_user and existing_user.id != current_user.id: - raise HTTPException( - status_code=409, detail="User with this email already exists" - ) - user_data = user_in.model_dump(exclude_unset=True) - current_user.sqlmodel_update(user_data) - session.add(current_user) - session.commit() - session.refresh(current_user) - return current_user + try: + return user_service.update_user_me( + session=session, current_user=current_user, user_in=user_in + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) @router.patch("/me/password", response_model=Message) @@ -106,17 +89,16 @@ def update_password_me( """ Update own password. """ - verified, _ = verify_password(body.current_password, current_user.hashed_password) - if not verified: - raise HTTPException(status_code=400, detail="Incorrect password") - if body.current_password == body.new_password: - raise HTTPException( - status_code=400, detail="New password cannot be the same as the current one" + try: + user_service.update_password_me( + session=session, + current_user=current_user, + current_password=body.current_password, + new_password=body.new_password, ) - hashed_password = get_password_hash(body.new_password) - current_user.hashed_password = hashed_password - session.add(current_user) - session.commit() + except ServiceError as exc: + _raise_http_from_service_error(exc) + return Message(message="Password updated successfully") @@ -133,12 +115,11 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: """ Delete own user. """ - if current_user.is_superuser: - raise HTTPException( - status_code=403, detail="Super users are not allowed to delete themselves" - ) - session.delete(current_user) - session.commit() + try: + user_service.delete_user_me(session=session, current_user=current_user) + except ServiceError as exc: + _raise_http_from_service_error(exc) + return Message(message="User deleted successfully") @@ -147,15 +128,10 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any: """ Create new user without the need to be logged in. """ - user = crud.get_user_by_email(session=session, email=user_in.email) - if user: - raise HTTPException( - status_code=400, - detail="The user with this email already exists in the system", - ) - user_create = UserCreate.model_validate(user_in) - user = crud.create_user(session=session, user_create=user_create) - return user + try: + return user_service.register_user(session=session, user_in=user_in) + except ServiceError as exc: + _raise_http_from_service_error(exc) @router.get("/{user_id}", response_model=UserPublic) @@ -165,17 +141,12 @@ def read_user_by_id( """ Get a specific user by id. """ - user = session.get(User, user_id) - if user == current_user: - return user - if not current_user.is_superuser: - raise HTTPException( - status_code=403, - detail="The user doesn't have enough privileges", + try: + return user_service.get_user_for_read( + session=session, user_id=user_id, current_user=current_user ) - if user is None: - raise HTTPException(status_code=404, detail="User not found") - return user + except ServiceError as exc: + _raise_http_from_service_error(exc) @router.patch( @@ -192,22 +163,12 @@ def update_user( """ Update a user. """ - - db_user = session.get(User, user_id) - if not db_user: - raise HTTPException( - status_code=404, - detail="The user with this id does not exist in the system", + try: + return user_service.update_user_by_admin( + session=session, user_id=user_id, user_in=user_in ) - if user_in.email: - existing_user = crud.get_user_by_email(session=session, email=user_in.email) - if existing_user and existing_user.id != user_id: - raise HTTPException( - status_code=409, detail="User with this email already exists" - ) - - db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in) - return db_user + except ServiceError as exc: + _raise_http_from_service_error(exc) @router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)]) @@ -217,15 +178,11 @@ def delete_user( """ Delete a user. """ - user = session.get(User, user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - if user == current_user: - raise HTTPException( - status_code=403, detail="Super users are not allowed to delete themselves" + try: + user_service.delete_user_by_admin( + session=session, current_user=current_user, user_id=user_id ) - statement = delete(Item).where(col(Item.owner_id) == user_id) - session.exec(statement) - session.delete(user) - session.commit() + except ServiceError as exc: + _raise_http_from_service_error(exc) + return Message(message="User deleted successfully") diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..3f7c5584db 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,8 +1,8 @@ from sqlmodel import Session, create_engine, select -from app import crud from app.core.config import settings -from app.models import User, UserCreate +from app.models import User +from app.services import user_service engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) @@ -21,13 +21,10 @@ def init_db(session: Session) -> None: # This works because the models are already imported and registered from app.models # SQLModel.metadata.create_all(engine) - user = session.exec( - select(User).where(User.email == settings.FIRST_SUPERUSER) - ).first() + user = session.exec(select(User).where(User.email == settings.FIRST_SUPERUSER)).first() if not user: - user_in = UserCreate( + user_service.ensure_superuser_exists( + session=session, email=settings.FIRST_SUPERUSER, password=settings.FIRST_SUPERUSER_PASSWORD, - is_superuser=True, ) - user = crud.create_user(session=session, user_create=user_in) diff --git a/backend/app/crud.py b/backend/app/crud.py index a8ceba6444..c034681653 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,68 +1,29 @@ import uuid from typing import Any -from sqlmodel import Session, select +from sqlmodel import Session -from app.core.security import get_password_hash, verify_password from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.services import auth_service, item_service, user_service def create_user(*, session: Session, user_create: UserCreate) -> User: - db_obj = User.model_validate( - user_create, update={"hashed_password": get_password_hash(user_create.password)} - ) - session.add(db_obj) - session.commit() - session.refresh(db_obj) - return db_obj + return user_service.create_user(session=session, user_create=user_create) def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: - user_data = user_in.model_dump(exclude_unset=True) - extra_data = {} - if "password" in user_data: - password = user_data["password"] - hashed_password = get_password_hash(password) - extra_data["hashed_password"] = hashed_password - db_user.sqlmodel_update(user_data, update=extra_data) - session.add(db_user) - session.commit() - session.refresh(db_user) - return db_user + return user_service.update_user(session=session, db_user=db_user, user_in=user_in) def get_user_by_email(*, session: Session, email: str) -> User | None: - statement = select(User).where(User.email == email) - session_user = session.exec(statement).first() - return session_user - - -# Dummy hash to use for timing attack prevention when user is not found -# This is an Argon2 hash of a random password, used to ensure constant-time comparison -DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$MjQyZWE1MzBjYjJlZTI0Yw$YTU4NGM5ZTZmYjE2NzZlZjY0ZWY3ZGRkY2U2OWFjNjk" + return user_service.get_user_by_email(session=session, email=email) def authenticate(*, session: Session, email: str, password: str) -> User | None: - db_user = get_user_by_email(session=session, email=email) - if not db_user: - # Prevent timing attacks by running password verification even when user doesn't exist - # This ensures the response time is similar whether or not the email exists - verify_password(password, DUMMY_HASH) - return None - verified, updated_password_hash = verify_password(password, db_user.hashed_password) - if not verified: - return None - if updated_password_hash: - db_user.hashed_password = updated_password_hash - session.add(db_user) - session.commit() - session.refresh(db_user) - return db_user + return auth_service.authenticate(session=session, email=email, password=password) def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: - db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) - session.add(db_item) - session.commit() - session.refresh(db_item) - return db_item + return item_service.create_item_for_owner( + session=session, item_in=item_in, owner_id=owner_id + ) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000000..79a5ee9f55 --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1,3 @@ +from . import item_repository, user_repository + +__all__ = ["item_repository", "user_repository"] diff --git a/backend/app/repositories/item_repository.py b/backend/app/repositories/item_repository.py new file mode 100644 index 0000000000..13d6dd62f3 --- /dev/null +++ b/backend/app/repositories/item_repository.py @@ -0,0 +1,61 @@ +import uuid + +from sqlmodel import Session, col, delete, func, select + +from app.models import Item + + +def get_by_id(*, session: Session, item_id: uuid.UUID) -> Item | None: + return session.get(Item, item_id) + + +def list_all(*, session: Session, skip: int = 0, limit: int = 100) -> tuple[list[Item], int]: + count_statement = select(func.count()).select_from(Item) + count = session.exec(count_statement).one() + statement = ( + select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit) + ) + items = list(session.exec(statement).all()) + return items, count + + +def list_by_owner( + *, session: Session, owner_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> tuple[list[Item], int]: + count_statement = ( + select(func.count()).select_from(Item).where(Item.owner_id == owner_id) + ) + count = session.exec(count_statement).one() + statement = ( + select(Item) + .where(Item.owner_id == owner_id) + .order_by(col(Item.created_at).desc()) + .offset(skip) + .limit(limit) + ) + items = list(session.exec(statement).all()) + return items, count + + +def create(*, session: Session, item: Item) -> Item: + session.add(item) + session.commit() + session.refresh(item) + return item + + +def save(*, session: Session, item: Item) -> Item: + session.add(item) + session.commit() + session.refresh(item) + return item + + +def delete_one(*, session: Session, item: Item) -> None: + session.delete(item) + session.commit() + + +def delete_by_owner(*, session: Session, owner_id: uuid.UUID) -> None: + statement = delete(Item).where(col(Item.owner_id) == owner_id) + session.exec(statement) diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py new file mode 100644 index 0000000000..5039a0b2e9 --- /dev/null +++ b/backend/app/repositories/user_repository.py @@ -0,0 +1,44 @@ +import uuid + +from sqlmodel import Session, col, func, select + +from app.models import User + + +def get_by_id(*, session: Session, user_id: uuid.UUID) -> User | None: + return session.get(User, user_id) + + +def get_by_email(*, session: Session, email: str) -> User | None: + statement = select(User).where(User.email == email) + return session.exec(statement).first() + + +def list_users(*, session: Session, skip: int = 0, limit: int = 100) -> tuple[list[User], int]: + count_statement = select(func.count()).select_from(User) + count = session.exec(count_statement).one() + + statement = ( + select(User).order_by(col(User.created_at).desc()).offset(skip).limit(limit) + ) + users = list(session.exec(statement).all()) + return users, count + + +def create(*, session: Session, user: User) -> User: + session.add(user) + session.commit() + session.refresh(user) + return user + + +def save(*, session: Session, user: User) -> User: + session.add(user) + session.commit() + session.refresh(user) + return user + + +def delete(*, session: Session, user: User) -> None: + session.delete(user) + session.commit() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000000..668c05f4e8 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,3 @@ +from . import auth_service, item_service, user_service + +__all__ = ["auth_service", "item_service", "user_service"] diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000000..a97dcfcb69 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,25 @@ +from sqlmodel import Session + +from app.core.security import verify_password +from app.models import User +from app.repositories import user_repository + +# Dummy hash to use for timing attack prevention when user is not found. +# This is an Argon2 hash of a random password. +DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$MjQyZWE1MzBjYjJlZTI0Yw$YTU4NGM5ZTZmYjE2NzZlZjY0ZWY3ZGRkY2U2OWFjNjk" + + +def authenticate(*, session: Session, email: str, password: str) -> User | None: + user = user_repository.get_by_email(session=session, email=email) + if user is None: + verify_password(password, DUMMY_HASH) + return None + + verified, updated_password_hash = verify_password(password, user.hashed_password) + if not verified: + return None + + if updated_password_hash: + user.hashed_password = updated_password_hash + user_repository.save(session=session, user=user) + return user diff --git a/backend/app/services/exceptions.py b/backend/app/services/exceptions.py new file mode 100644 index 0000000000..75bae685c0 --- /dev/null +++ b/backend/app/services/exceptions.py @@ -0,0 +1,22 @@ +class ServiceError(Exception): + status_code = 400 + + def __init__(self, detail: str) -> None: + super().__init__(detail) + self.detail = detail + + +class BadRequestError(ServiceError): + status_code = 400 + + +class ForbiddenError(ServiceError): + status_code = 403 + + +class NotFoundError(ServiceError): + status_code = 404 + + +class ConflictError(ServiceError): + status_code = 409 diff --git a/backend/app/services/item_service.py b/backend/app/services/item_service.py new file mode 100644 index 0000000000..2185f3eee1 --- /dev/null +++ b/backend/app/services/item_service.py @@ -0,0 +1,59 @@ +import uuid + +from sqlmodel import Session + +from app.models import Item, ItemCreate, ItemUpdate, User +from app.repositories import item_repository +from app.services.exceptions import ForbiddenError, NotFoundError + + +def list_items_for_user( + *, session: Session, current_user: User, skip: int = 0, limit: int = 100 +) -> tuple[list[Item], int]: + if current_user.is_superuser: + return item_repository.list_all(session=session, skip=skip, limit=limit) + return item_repository.list_by_owner( + session=session, owner_id=current_user.id, skip=skip, limit=limit + ) + + +def get_item_for_user(*, session: Session, current_user: User, item_id: uuid.UUID) -> Item: + item = item_repository.get_by_id(session=session, item_id=item_id) + if item is None: + raise NotFoundError("Item not found") + if not current_user.is_superuser and item.owner_id != current_user.id: + raise ForbiddenError("Not enough permissions") + return item + + +def create_item_for_owner( + *, session: Session, item_in: ItemCreate, owner_id: uuid.UUID +) -> Item: + item = Item.model_validate(item_in, update={"owner_id": owner_id}) + return item_repository.create(session=session, item=item) + + +def create_item_for_user( + *, session: Session, current_user: User, item_in: ItemCreate +) -> Item: + return create_item_for_owner(session=session, item_in=item_in, owner_id=current_user.id) + + +def update_item_for_user( + *, + session: Session, + current_user: User, + item_id: uuid.UUID, + item_in: ItemUpdate, +) -> Item: + item = get_item_for_user(session=session, current_user=current_user, item_id=item_id) + update_dict = item_in.model_dump(exclude_unset=True) + item.sqlmodel_update(update_dict) + return item_repository.save(session=session, item=item) + + +def delete_item_for_user( + *, session: Session, current_user: User, item_id: uuid.UUID +) -> None: + item = get_item_for_user(session=session, current_user=current_user, item_id=item_id) + item_repository.delete_one(session=session, item=item) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000000..c70543fab8 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,158 @@ +import uuid + +from sqlmodel import Session + +from app.core.security import get_password_hash, verify_password +from app.models import ( + User, + UserCreate, + UserRegister, + UserUpdate, + UserUpdateMe, +) +from app.repositories import item_repository, user_repository +from app.services.exceptions import ( + BadRequestError, + ConflictError, + ForbiddenError, + NotFoundError, +) + + +def list_users(*, session: Session, skip: int = 0, limit: int = 100) -> tuple[list[User], int]: + return user_repository.list_users(session=session, skip=skip, limit=limit) + + +def get_user_by_email(*, session: Session, email: str) -> User | None: + return user_repository.get_by_email(session=session, email=email) + + +def get_user_by_id(*, session: Session, user_id: uuid.UUID) -> User | None: + return user_repository.get_by_id(session=session, user_id=user_id) + + +def create_user(*, session: Session, user_create: UserCreate) -> User: + user = User.model_validate( + user_create, update={"hashed_password": get_password_hash(user_create.password)} + ) + return user_repository.create(session=session, user=user) + + +def create_user_for_admin(*, session: Session, user_in: UserCreate) -> User: + existing_user = get_user_by_email(session=session, email=user_in.email) + if existing_user: + raise BadRequestError("The user with this email already exists in the system.") + return create_user(session=session, user_create=user_in) + + +def register_user(*, session: Session, user_in: UserRegister) -> User: + existing_user = get_user_by_email(session=session, email=user_in.email) + if existing_user: + raise BadRequestError("The user with this email already exists in the system") + user_create = UserCreate.model_validate(user_in) + return create_user(session=session, user_create=user_create) + + +def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> User: + user_data = user_in.model_dump(exclude_unset=True) + extra_data = {} + if "password" in user_data: + password = user_data["password"] + if isinstance(password, str): + extra_data["hashed_password"] = get_password_hash(password) + db_user.sqlmodel_update(user_data, update=extra_data) + return user_repository.save(session=session, user=db_user) + + +def update_user_me(*, session: Session, current_user: User, user_in: UserUpdateMe) -> User: + if user_in.email: + existing_user = get_user_by_email(session=session, email=user_in.email) + if existing_user and existing_user.id != current_user.id: + raise ConflictError("User with this email already exists") + + user_data = user_in.model_dump(exclude_unset=True) + current_user.sqlmodel_update(user_data) + return user_repository.save(session=session, user=current_user) + + +def update_password_me( + *, session: Session, current_user: User, current_password: str, new_password: str +) -> None: + verified, _ = verify_password(current_password, current_user.hashed_password) + if not verified: + raise BadRequestError("Incorrect password") + if current_password == new_password: + raise BadRequestError("New password cannot be the same as the current one") + + current_user.hashed_password = get_password_hash(new_password) + user_repository.save(session=session, user=current_user) + + +def delete_user_me(*, session: Session, current_user: User) -> None: + if current_user.is_superuser: + raise ForbiddenError("Super users are not allowed to delete themselves") + user_repository.delete(session=session, user=current_user) + + +def get_user_for_read(*, session: Session, user_id: uuid.UUID, current_user: User) -> User: + user = get_user_by_id(session=session, user_id=user_id) + if user == current_user: + return user + if not current_user.is_superuser: + raise ForbiddenError("The user doesn't have enough privileges") + if user is None: + raise NotFoundError("User not found") + return user + + +def update_user_by_admin( + *, session: Session, user_id: uuid.UUID, user_in: UserUpdate +) -> User: + db_user = get_user_by_id(session=session, user_id=user_id) + if db_user is None: + raise NotFoundError("The user with this id does not exist in the system") + + if user_in.email: + existing_user = get_user_by_email(session=session, email=user_in.email) + if existing_user and existing_user.id != user_id: + raise ConflictError("User with this email already exists") + + return update_user(session=session, db_user=db_user, user_in=user_in) + + +def delete_user_by_admin( + *, session: Session, current_user: User, user_id: uuid.UUID +) -> None: + user = get_user_by_id(session=session, user_id=user_id) + if user is None: + raise NotFoundError("User not found") + if user == current_user: + raise ForbiddenError("Super users are not allowed to delete themselves") + + item_repository.delete_by_owner(session=session, owner_id=user_id) + session.delete(user) + session.commit() + + +def create_private_user( + *, session: Session, email: str, password: str, full_name: str +) -> User: + user = User( + email=email, + full_name=full_name, + hashed_password=get_password_hash(password), + ) + return user_repository.create(session=session, user=user) + + +def ensure_superuser_exists(*, session: Session, email: str, password: str) -> User: + existing_user = get_user_by_email(session=session, email=email) + if existing_user: + return existing_user + + user_in = UserCreate( + email=email, + password=password, + is_superuser=True, + ) + return create_user(session=session, user_create=user_in) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8afe946cb5..8ebfe3d544 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -18,8 +18,18 @@ OpenAPI.TOKEN = async () => { return localStorage.getItem("access_token") || "" } +const isAuthUserNotFound = (error: ApiError) => { + if (error.status !== 404) return false + if (!error.body || typeof error.body !== "object") return false + const detail = (error.body as { detail?: unknown }).detail + return detail === "User not found" +} + const handleApiError = (error: Error) => { - if (error instanceof ApiError && [401, 403].includes(error.status)) { + if ( + error instanceof ApiError && + ([401, 403].includes(error.status) || isAuthUserNotFound(error)) + ) { localStorage.removeItem("access_token") window.location.href = "/login" } diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 8849130b4c..08d665fef8 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -65,6 +65,7 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({ } as any) export interface FileRoutesByFullPath { + '/': typeof LayoutIndexRoute '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute @@ -72,7 +73,6 @@ export interface FileRoutesByFullPath { '/admin': typeof LayoutAdminRoute '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute - '/': typeof LayoutIndexRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -99,6 +99,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/' | '/login' | '/recover-password' | '/reset-password' @@ -106,7 +107,6 @@ export interface FileRouteTypes { | '/admin' | '/items' | '/settings' - | '/' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -171,7 +171,7 @@ declare module '@tanstack/react-router' { '/_layout': { id: '/_layout' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } From 87ee523d399a545b9f23bbdb9b6f8b4b117fb687 Mon Sep 17 00:00:00 2001 From: Ziye WANG Date: Sun, 22 Feb 2026 10:57:34 +0100 Subject: [PATCH 02/25] first template commit success --- ...fd3f_add_template_and_generation_models.py | 104 ++++ backend/app/api/main.py | 14 +- backend/app/api/routes/generate.py | 53 ++ backend/app/api/routes/generations.py | 90 +++ backend/app/api/routes/templates.py | 176 ++++++ backend/app/models.py | 245 +++++++- backend/app/repositories/__init__.py | 9 +- .../app/repositories/generation_repository.py | 45 ++ .../app/repositories/template_repository.py | 122 ++++ backend/app/services/__init__.py | 18 +- backend/app/services/generation_service.py | 160 ++++++ backend/app/services/template_ai_service.py | 170 ++++++ backend/app/services/template_service.py | 162 ++++++ backend/app/template_utils.py | 81 +++ frontend-dev.err | 0 frontend-dev.log | 9 + .../src/components/Sidebar/AppSidebar.tsx | 6 +- frontend/src/lib/templateMvpApi.ts | 293 ++++++++++ frontend/src/lib/templateVariables.ts | 103 ++++ frontend/src/routeTree.gen.ts | 84 +++ frontend/src/routes/_layout/generate.tsx | 501 ++++++++++++++++ frontend/src/routes/_layout/history.tsx | 204 +++++++ .../src/routes/_layout/template-editor.tsx | 539 ++++++++++++++++++ frontend/src/routes/_layout/templates.tsx | 205 +++++++ 24 files changed, 3386 insertions(+), 7 deletions(-) create mode 100644 backend/app/alembic/versions/6f44bc66fd3f_add_template_and_generation_models.py create mode 100644 backend/app/api/routes/generate.py create mode 100644 backend/app/api/routes/generations.py create mode 100644 backend/app/api/routes/templates.py create mode 100644 backend/app/repositories/generation_repository.py create mode 100644 backend/app/repositories/template_repository.py create mode 100644 backend/app/services/generation_service.py create mode 100644 backend/app/services/template_ai_service.py create mode 100644 backend/app/services/template_service.py create mode 100644 backend/app/template_utils.py create mode 100644 frontend-dev.err create mode 100644 frontend-dev.log create mode 100644 frontend/src/lib/templateMvpApi.ts create mode 100644 frontend/src/lib/templateVariables.ts create mode 100644 frontend/src/routes/_layout/generate.tsx create mode 100644 frontend/src/routes/_layout/history.tsx create mode 100644 frontend/src/routes/_layout/template-editor.tsx create mode 100644 frontend/src/routes/_layout/templates.tsx diff --git a/backend/app/alembic/versions/6f44bc66fd3f_add_template_and_generation_models.py b/backend/app/alembic/versions/6f44bc66fd3f_add_template_and_generation_models.py new file mode 100644 index 0000000000..bd0c55ede5 --- /dev/null +++ b/backend/app/alembic/versions/6f44bc66fd3f_add_template_and_generation_models.py @@ -0,0 +1,104 @@ +"""Add template and generation models + +Revision ID: 6f44bc66fd3f +Revises: fe56fa70289e +Create Date: 2026-02-21 23:20:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = "6f44bc66fd3f" +down_revision = "fe56fa70289e" +branch_labels = None +depends_on = None + + +template_category_enum = sa.Enum( + "cover_letter", + "email", + "proposal", + "other", + name="templatecategory", +) +template_language_enum = sa.Enum( + "fr", + "en", + "zh", + "other", + name="templatelanguage", +) + + +def upgrade() -> None: + bind = op.get_bind() + template_category_enum.create(bind, checkfirst=True) + template_language_enum.create(bind, checkfirst=True) + + op.create_table( + "template", + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("category", template_category_enum, nullable=False), + sa.Column("language", template_language_enum, nullable=False), + sa.Column("tags", sa.JSON(), nullable=False), + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("is_archived", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "templateversion", + sa.Column("content", sa.Text(), nullable=False), + sa.Column("variables_schema", sa.JSON(), nullable=False), + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("template_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=False), + sa.ForeignKeyConstraint( + ["template_id"], ["template.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["created_by"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("template_id", "version"), + ) + + op.create_table( + "generation", + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("input_text", sa.Text(), nullable=False), + sa.Column("extracted_values", sa.JSON(), nullable=False), + sa.Column("output_text", sa.Text(), nullable=False), + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("template_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("template_version_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["template_id"], ["template.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["template_version_id"], ["templateversion.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("generation") + op.drop_table("templateversion") + op.drop_table("template") + + bind = op.get_bind() + template_language_enum.drop(bind, checkfirst=True) + template_category_enum.drop(bind, checkfirst=True) diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..2229e2b075 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,15 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import ( + generate, + generations, + items, + login, + private, + templates, + users, + utils, +) from app.core.config import settings api_router = APIRouter() @@ -8,6 +17,9 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(templates.router) +api_router.include_router(generate.router) +api_router.include_router(generations.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/generate.py b/backend/app/api/routes/generate.py new file mode 100644 index 0000000000..b524a955f7 --- /dev/null +++ b/backend/app/api/routes/generate.py @@ -0,0 +1,53 @@ +from typing import Any, NoReturn + +from fastapi import APIRouter, HTTPException + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + ExtractVariablesRequest, + ExtractVariablesResponse, + RenderTemplateRequest, + RenderTemplateResponse, +) +from app.services import generation_service +from app.services.exceptions import ServiceError + +router = APIRouter(prefix="/generate", tags=["generate"]) + + +def _raise_http_from_service_error(exc: ServiceError) -> NoReturn: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) + + +@router.post("/extract", response_model=ExtractVariablesResponse) +def extract_variables( + *, + session: SessionDep, + current_user: CurrentUser, + extract_in: ExtractVariablesRequest, +) -> Any: + try: + return generation_service.extract_values_for_user( + session=session, + current_user=current_user, + extract_in=extract_in, + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) + + +@router.post("/render", response_model=RenderTemplateResponse) +def render_template( + *, + session: SessionDep, + current_user: CurrentUser, + render_in: RenderTemplateRequest, +) -> Any: + try: + return generation_service.render_for_user( + session=session, + current_user=current_user, + render_in=render_in, + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) diff --git a/backend/app/api/routes/generations.py b/backend/app/api/routes/generations.py new file mode 100644 index 0000000000..ae0e93210b --- /dev/null +++ b/backend/app/api/routes/generations.py @@ -0,0 +1,90 @@ +import uuid +from typing import Any, NoReturn + +from fastapi import APIRouter, HTTPException + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + GenerationCreate, + GenerationPublic, + GenerationsPublic, + GenerationUpdate, +) +from app.services import generation_service +from app.services.exceptions import ServiceError + +router = APIRouter(prefix="/generations", tags=["generations"]) + + +def _raise_http_from_service_error(exc: ServiceError) -> NoReturn: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) + + +@router.get("/", response_model=GenerationsPublic) +def read_generations( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + generations, count = generation_service.list_generations_for_user( + session=session, + current_user=current_user, + skip=skip, + limit=limit, + ) + data = [GenerationPublic.model_validate(generation) for generation in generations] + return GenerationsPublic(data=data, count=count) + + +@router.get("/{generation_id}", response_model=GenerationPublic) +def read_generation( + session: SessionDep, current_user: CurrentUser, generation_id: uuid.UUID +) -> Any: + try: + generation = generation_service.get_generation_for_user( + session=session, + current_user=current_user, + generation_id=generation_id, + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) + + return GenerationPublic.model_validate(generation) + + +@router.post("/", response_model=GenerationPublic) +def create_generation( + *, + session: SessionDep, + current_user: CurrentUser, + generation_in: GenerationCreate, +) -> Any: + try: + generation = generation_service.create_generation_for_user( + session=session, + current_user=current_user, + generation_in=generation_in, + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) + + return GenerationPublic.model_validate(generation) + + +@router.patch("/{generation_id}", response_model=GenerationPublic) +def update_generation( + *, + session: SessionDep, + current_user: CurrentUser, + generation_id: uuid.UUID, + generation_in: GenerationUpdate, +) -> Any: + try: + generation = generation_service.update_generation_for_user( + session=session, + current_user=current_user, + generation_id=generation_id, + generation_in=generation_in, + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) + + return GenerationPublic.model_validate(generation) diff --git a/backend/app/api/routes/templates.py b/backend/app/api/routes/templates.py new file mode 100644 index 0000000000..b0a912e4c8 --- /dev/null +++ b/backend/app/api/routes/templates.py @@ -0,0 +1,176 @@ +import uuid +from typing import Any, NoReturn + +from fastapi import APIRouter, HTTPException + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + TemplateCategory, + TemplateCreate, + TemplateLanguage, + TemplateListPublic, + TemplatePublic, + TemplatesPublic, + TemplateUpdate, + TemplateVersionCreate, + TemplateVersionPublic, + TemplateVersionsPublic, +) +from app.services import template_service +from app.services.exceptions import ServiceError + +router = APIRouter(prefix="/templates", tags=["templates"]) + + +def _raise_http_from_service_error(exc: ServiceError) -> NoReturn: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) + + +def _build_template_public(session: SessionDep, template: Any) -> TemplatePublic: + latest_version = template_service.get_template_latest_version( + session=session, template_id=template.id + ) + versions_count = template_service.count_template_versions( + session=session, template_id=template.id + ) + + latest_version_public = ( + TemplateVersionPublic.model_validate(latest_version) + if latest_version is not None + else None + ) + + payload = template.model_dump() + payload["versions_count"] = versions_count + payload["latest_version"] = latest_version_public + return TemplatePublic.model_validate(payload) + + +@router.get("/", response_model=TemplatesPublic) +def read_templates( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100, + category: TemplateCategory | None = None, + language: TemplateLanguage | None = None, + search: str | None = None, +) -> Any: + templates, count = template_service.list_templates_for_user( + session=session, + current_user=current_user, + category=category, + language=language, + search=search, + skip=skip, + limit=limit, + ) + + data: list[TemplateListPublic] = [] + for template in templates: + latest_version = template_service.get_template_latest_version( + session=session, template_id=template.id + ) + versions_count = template_service.count_template_versions( + session=session, template_id=template.id + ) + + payload = template.model_dump() + payload["versions_count"] = versions_count + payload["latest_version_number"] = latest_version.version if latest_version else None + data.append(TemplateListPublic.model_validate(payload)) + + return TemplatesPublic(data=data, count=count) + + +@router.post("/", response_model=TemplatePublic) +def create_template( + *, + session: SessionDep, + current_user: CurrentUser, + template_in: TemplateCreate, +) -> Any: + template = template_service.create_template_for_user( + session=session, + current_user=current_user, + template_in=template_in, + ) + return _build_template_public(session, template) + + +@router.get("/{template_id}", response_model=TemplatePublic) +def read_template( + session: SessionDep, current_user: CurrentUser, template_id: uuid.UUID +) -> Any: + try: + template = template_service.get_template_for_user( + session=session, + current_user=current_user, + template_id=template_id, + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) + + return _build_template_public(session, template) + + +@router.patch("/{template_id}", response_model=TemplatePublic) +def update_template( + *, + session: SessionDep, + current_user: CurrentUser, + template_id: uuid.UUID, + template_in: TemplateUpdate, +) -> Any: + try: + template = template_service.update_template_for_user( + session=session, + current_user=current_user, + template_id=template_id, + template_in=template_in, + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) + + return _build_template_public(session, template) + + +@router.get("/{template_id}/versions", response_model=TemplateVersionsPublic) +def read_template_versions( + *, + session: SessionDep, + current_user: CurrentUser, + template_id: uuid.UUID, +) -> Any: + try: + versions = template_service.list_template_versions_for_user( + session=session, + current_user=current_user, + template_id=template_id, + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) + + data = [TemplateVersionPublic.model_validate(version) for version in versions] + return TemplateVersionsPublic(data=data, count=len(data)) + + +@router.post("/{template_id}/versions", response_model=TemplateVersionPublic) +def create_template_version( + *, + session: SessionDep, + current_user: CurrentUser, + template_id: uuid.UUID, + version_in: TemplateVersionCreate, +) -> Any: + try: + version = template_service.create_template_version_for_user( + session=session, + current_user=current_user, + template_id=template_id, + version_in=version_in, + ) + except ServiceError as exc: + _raise_http_from_service_error(exc) + + return TemplateVersionPublic.model_validate(version) diff --git a/backend/app/models.py b/backend/app/models.py index b5132e0e2c..61e241135a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,8 +1,10 @@ import uuid from datetime import datetime, timezone +from enum import Enum +from typing import Any from pydantic import EmailStr -from sqlalchemy import DateTime +from sqlalchemy import JSON, Column, DateTime, UniqueConstraint from sqlmodel import Field, Relationship, SQLModel @@ -54,6 +56,15 @@ class User(UserBase, table=True): sa_type=DateTime(timezone=True), # type: ignore ) items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + templates: list["Template"] = Relationship( + back_populates="owner", cascade_delete=True + ) + template_versions: list["TemplateVersion"] = Relationship( + back_populates="creator", cascade_delete=True + ) + generations: list["Generation"] = Relationship( + back_populates="owner", cascade_delete=True + ) # Properties to return via API, id is always required @@ -108,6 +119,238 @@ class ItemsPublic(SQLModel): count: int +class TemplateCategory(str, Enum): + cover_letter = "cover_letter" + email = "email" + proposal = "proposal" + other = "other" + + +class TemplateLanguage(str, Enum): + fr = "fr" + en = "en" + zh = "zh" + other = "other" + + +class TemplateVariableType(str, Enum): + text = "text" + list = "list" + + +class TemplateVariableConfig(SQLModel): + required: bool = False + type: TemplateVariableType = TemplateVariableType.text + description: str = "" + example: Any | None = None + default: Any | None = None + + +class TemplateBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + category: TemplateCategory = TemplateCategory.other + language: TemplateLanguage = TemplateLanguage.en + tags: list[str] = Field(default_factory=list) + + +class TemplateCreate(TemplateBase): + pass + + +class TemplateUpdate(SQLModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + category: TemplateCategory | None = None + language: TemplateLanguage | None = None + tags: list[str] | None = None + is_archived: bool | None = None + + +class TemplateVersionBase(SQLModel): + content: str = Field(min_length=1) + variables_schema: dict[str, TemplateVariableConfig] = Field(default_factory=dict) + + +class TemplateVersionCreate(TemplateVersionBase): + pass + + +class Template(TemplateBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + tags: list[str] = Field( + default_factory=list, sa_column=Column(JSON, nullable=False) + ) + is_archived: bool = False + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + updated_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + + owner: User | None = Relationship(back_populates="templates") + versions: list["TemplateVersion"] = Relationship( + back_populates="template", cascade_delete=True + ) + generations: list["Generation"] = Relationship( + back_populates="template", cascade_delete=True + ) + + +class TemplateVersion(TemplateVersionBase, table=True): + __table_args__ = (UniqueConstraint("template_id", "version"),) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + template_id: uuid.UUID = Field( + foreign_key="template.id", nullable=False, ondelete="CASCADE" + ) + version: int = Field(default=1, ge=1) + variables_schema: dict[str, Any] = Field( + default_factory=dict, sa_column=Column(JSON, nullable=False) + ) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + created_by: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + + template: Template | None = Relationship(back_populates="versions") + creator: User | None = Relationship(back_populates="template_versions") + generations: list["Generation"] = Relationship(back_populates="template_version") + + +class TemplateVersionPublic(TemplateVersionBase): + id: uuid.UUID + template_id: uuid.UUID + version: int + created_at: datetime | None = None + created_by: uuid.UUID + + +class TemplatePublic(TemplateBase): + id: uuid.UUID + user_id: uuid.UUID + is_archived: bool + created_at: datetime | None = None + updated_at: datetime | None = None + versions_count: int = 0 + latest_version: TemplateVersionPublic | None = None + + +class TemplateListPublic(TemplateBase): + id: uuid.UUID + user_id: uuid.UUID + is_archived: bool + created_at: datetime | None = None + updated_at: datetime | None = None + versions_count: int = 0 + latest_version_number: int | None = None + + +class TemplatesPublic(SQLModel): + data: list[TemplateListPublic] + count: int + + +class TemplateVersionsPublic(SQLModel): + data: list[TemplateVersionPublic] + count: int + + +class GenerationBase(SQLModel): + title: str = Field(min_length=1, max_length=255) + input_text: str = Field(min_length=1) + extracted_values: dict[str, Any] = Field(default_factory=dict) + output_text: str = Field(min_length=1) + + +class GenerationCreate(GenerationBase): + template_id: uuid.UUID + template_version_id: uuid.UUID + + +class GenerationUpdate(SQLModel): + title: str | None = Field(default=None, min_length=1, max_length=255) + extracted_values: dict[str, Any] | None = None + output_text: str | None = Field(default=None, min_length=1) + + +class Generation(GenerationBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + template_id: uuid.UUID = Field( + foreign_key="template.id", nullable=False, ondelete="CASCADE" + ) + template_version_id: uuid.UUID = Field( + foreign_key="templateversion.id", nullable=False, ondelete="CASCADE" + ) + extracted_values: dict[str, Any] = Field( + default_factory=dict, sa_column=Column(JSON, nullable=False) + ) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + updated_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + + owner: User | None = Relationship(back_populates="generations") + template: Template | None = Relationship(back_populates="generations") + template_version: TemplateVersion | None = Relationship(back_populates="generations") + + +class GenerationPublic(GenerationBase): + id: uuid.UUID + user_id: uuid.UUID + template_id: uuid.UUID + template_version_id: uuid.UUID + created_at: datetime | None = None + updated_at: datetime | None = None + + +class GenerationsPublic(SQLModel): + data: list[GenerationPublic] + count: int + + +class ExtractVariablesRequest(SQLModel): + template_version_id: uuid.UUID + input_text: str = Field(min_length=1) + profile_context: dict[str, Any] = Field(default_factory=dict) + + +class ExtractVariablesResponse(SQLModel): + values: dict[str, Any] + missing_required: list[str] + confidence: dict[str, float] + notes: dict[str, str] = Field(default_factory=dict) + + +class RenderStyle(SQLModel): + tone: str = "professional" + length: str = "medium" + + +class RenderTemplateRequest(SQLModel): + template_version_id: uuid.UUID + values: dict[str, Any] = Field(default_factory=dict) + style: RenderStyle = Field(default_factory=RenderStyle) + + +class RenderTemplateResponse(SQLModel): + output_text: str + + # Generic message class Message(SQLModel): message: str diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py index 79a5ee9f55..b65682461f 100644 --- a/backend/app/repositories/__init__.py +++ b/backend/app/repositories/__init__.py @@ -1,3 +1,8 @@ -from . import item_repository, user_repository +from . import ( + generation_repository, + item_repository, + template_repository, + user_repository, +) -__all__ = ["item_repository", "user_repository"] +__all__ = ["item_repository", "user_repository", "template_repository", "generation_repository"] diff --git a/backend/app/repositories/generation_repository.py b/backend/app/repositories/generation_repository.py new file mode 100644 index 0000000000..a4f8e95e54 --- /dev/null +++ b/backend/app/repositories/generation_repository.py @@ -0,0 +1,45 @@ +import uuid + +from sqlmodel import Session, col, func, select + +from app.models import Generation + + +def get_generation_by_id(*, session: Session, generation_id: uuid.UUID) -> Generation | None: + return session.get(Generation, generation_id) + + +def list_generations( + *, + session: Session, + user_id: uuid.UUID, + is_superuser: bool, + skip: int = 0, + limit: int = 100, +) -> tuple[list[Generation], int]: + statement = select(Generation) + count_statement = select(func.count()).select_from(Generation) + + if not is_superuser: + statement = statement.where(Generation.user_id == user_id) + count_statement = count_statement.where(Generation.user_id == user_id) + + statement = statement.order_by(col(Generation.created_at).desc()).offset(skip).limit(limit) + + count = session.exec(count_statement).one() + generations = list(session.exec(statement).all()) + return generations, count + + +def create_generation(*, session: Session, generation: Generation) -> Generation: + session.add(generation) + session.commit() + session.refresh(generation) + return generation + + +def save_generation(*, session: Session, generation: Generation) -> Generation: + session.add(generation) + session.commit() + session.refresh(generation) + return generation diff --git a/backend/app/repositories/template_repository.py b/backend/app/repositories/template_repository.py new file mode 100644 index 0000000000..a876a81ffd --- /dev/null +++ b/backend/app/repositories/template_repository.py @@ -0,0 +1,122 @@ +import uuid + +from sqlmodel import Session, col, func, select + +from app.models import ( + Template, + TemplateCategory, + TemplateLanguage, + TemplateVersion, +) + + +def get_template_by_id(*, session: Session, template_id: uuid.UUID) -> Template | None: + return session.get(Template, template_id) + + +def list_templates( + *, + session: Session, + user_id: uuid.UUID, + is_superuser: bool, + category: TemplateCategory | None = None, + language: TemplateLanguage | None = None, + search: str | None = None, + skip: int = 0, + limit: int = 100, +) -> tuple[list[Template], int]: + statement = select(Template) + count_statement = select(func.count()).select_from(Template) + + if not is_superuser: + statement = statement.where(Template.user_id == user_id) + count_statement = count_statement.where(Template.user_id == user_id) + + if category: + statement = statement.where(Template.category == category) + count_statement = count_statement.where(Template.category == category) + + if language: + statement = statement.where(Template.language == language) + count_statement = count_statement.where(Template.language == language) + + if search: + keyword = f"%{search}%" + statement = statement.where(Template.name.ilike(keyword)) + count_statement = count_statement.where(Template.name.ilike(keyword)) + + statement = statement.order_by(col(Template.updated_at).desc()).offset(skip).limit(limit) + + count = session.exec(count_statement).one() + templates = list(session.exec(statement).all()) + return templates, count + + +def create_template(*, session: Session, template: Template) -> Template: + session.add(template) + session.commit() + session.refresh(template) + return template + + +def save_template(*, session: Session, template: Template) -> Template: + session.add(template) + session.commit() + session.refresh(template) + return template + + +def list_template_versions( + *, session: Session, template_id: uuid.UUID +) -> list[TemplateVersion]: + statement = ( + select(TemplateVersion) + .where(TemplateVersion.template_id == template_id) + .order_by(col(TemplateVersion.version).desc()) + ) + return list(session.exec(statement).all()) + + +def get_template_version_by_id( + *, session: Session, template_version_id: uuid.UUID +) -> TemplateVersion | None: + return session.get(TemplateVersion, template_version_id) + + +def get_latest_template_version( + *, session: Session, template_id: uuid.UUID +) -> TemplateVersion | None: + statement = ( + select(TemplateVersion) + .where(TemplateVersion.template_id == template_id) + .order_by(col(TemplateVersion.version).desc()) + .limit(1) + ) + return session.exec(statement).first() + + +def count_template_versions(*, session: Session, template_id: uuid.UUID) -> int: + statement = ( + select(func.count()) + .select_from(TemplateVersion) + .where(TemplateVersion.template_id == template_id) + ) + return session.exec(statement).one() + + +def get_next_version_number(*, session: Session, template_id: uuid.UUID) -> int: + statement = ( + select(func.max(TemplateVersion.version)) + .where(TemplateVersion.template_id == template_id) + ) + current_max = session.exec(statement).one() + if current_max is None: + return 1 + return int(current_max) + 1 + + +def create_template_version(*, session: Session, template_version: TemplateVersion) -> TemplateVersion: + session.add(template_version) + session.commit() + session.refresh(template_version) + return template_version diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 668c05f4e8..951af06364 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,3 +1,17 @@ -from . import auth_service, item_service, user_service +from . import ( + auth_service, + generation_service, + item_service, + template_ai_service, + template_service, + user_service, +) -__all__ = ["auth_service", "item_service", "user_service"] +__all__ = [ + "auth_service", + "item_service", + "user_service", + "template_service", + "template_ai_service", + "generation_service", +] diff --git a/backend/app/services/generation_service.py b/backend/app/services/generation_service.py new file mode 100644 index 0000000000..fbad345358 --- /dev/null +++ b/backend/app/services/generation_service.py @@ -0,0 +1,160 @@ +import uuid + +from sqlmodel import Session + +from app.models import ( + ExtractVariablesRequest, + ExtractVariablesResponse, + Generation, + GenerationCreate, + GenerationUpdate, + RenderTemplateRequest, + RenderTemplateResponse, + TemplateVariableConfig, + User, + get_datetime_utc, +) +from app.repositories import generation_repository +from app.services.exceptions import BadRequestError, ForbiddenError, NotFoundError +from app.template_utils import is_missing_value, normalize_variables_schema + +from . import template_ai_service, template_service + + +def extract_values_for_user( + *, + session: Session, + current_user: User, + extract_in: ExtractVariablesRequest, +) -> ExtractVariablesResponse: + template_version = template_service.get_template_version_for_user( + session=session, + current_user=current_user, + template_version_id=extract_in.template_version_id, + ) + + normalized_schema = normalize_variables_schema( + template_version.content, + template_version.variables_schema, + ) + + values, missing_required, confidence, notes = template_ai_service.extract_variables( + input_text=extract_in.input_text, + variables_schema=normalized_schema, + profile_context=extract_in.profile_context, + ) + + return ExtractVariablesResponse( + values=values, + missing_required=missing_required, + confidence=confidence, + notes=notes, + ) + + +def render_for_user( + *, + session: Session, + current_user: User, + render_in: RenderTemplateRequest, +) -> RenderTemplateResponse: + template_version = template_service.get_template_version_for_user( + session=session, + current_user=current_user, + template_version_id=render_in.template_version_id, + ) + + normalized_schema = normalize_variables_schema( + template_version.content, + template_version.variables_schema, + ) + + missing_required: list[str] = [] + for variable, raw_config in normalized_schema.items(): + config = TemplateVariableConfig.model_validate(raw_config) + if config.required and is_missing_value(render_in.values.get(variable), config.type): + missing_required.append(variable) + + if missing_required: + missing = ", ".join(missing_required) + raise BadRequestError(f"Missing required variables: {missing}") + + style = render_in.style.model_dump() if render_in.style else {} + output_text = template_ai_service.render_template( + content=template_version.content, + values=render_in.values, + style=style, + ) + return RenderTemplateResponse(output_text=output_text) + + +def list_generations_for_user( + *, session: Session, current_user: User, skip: int = 0, limit: int = 100 +) -> tuple[list[Generation], int]: + return generation_repository.list_generations( + session=session, + user_id=current_user.id, + is_superuser=current_user.is_superuser, + skip=skip, + limit=limit, + ) + + +def get_generation_for_user( + *, session: Session, current_user: User, generation_id: uuid.UUID +) -> Generation: + generation = generation_repository.get_generation_by_id( + session=session, generation_id=generation_id + ) + if generation is None: + raise NotFoundError("Generation not found") + if not current_user.is_superuser and generation.user_id != current_user.id: + raise ForbiddenError("Not enough permissions") + return generation + + +def create_generation_for_user( + *, session: Session, current_user: User, generation_in: GenerationCreate +) -> Generation: + template = template_service.get_template_for_user( + session=session, + current_user=current_user, + template_id=generation_in.template_id, + ) + template_version = template_service.get_template_version_for_user( + session=session, + current_user=current_user, + template_version_id=generation_in.template_version_id, + ) + + if template_version.template_id != template.id: + raise BadRequestError("template_version_id does not belong to template_id") + + generation = Generation.model_validate( + generation_in, + update={ + "user_id": current_user.id, + "updated_at": get_datetime_utc(), + }, + ) + return generation_repository.create_generation(session=session, generation=generation) + + +def update_generation_for_user( + *, + session: Session, + current_user: User, + generation_id: uuid.UUID, + generation_in: GenerationUpdate, +) -> Generation: + generation = get_generation_for_user( + session=session, + current_user=current_user, + generation_id=generation_id, + ) + + update_data = generation_in.model_dump(exclude_unset=True) + generation.sqlmodel_update(update_data) + generation.updated_at = get_datetime_utc() + + return generation_repository.save_generation(session=session, generation=generation) diff --git a/backend/app/services/template_ai_service.py b/backend/app/services/template_ai_service.py new file mode 100644 index 0000000000..a3091fad49 --- /dev/null +++ b/backend/app/services/template_ai_service.py @@ -0,0 +1,170 @@ +import re +from typing import Any + +from app.models import TemplateVariableConfig, TemplateVariableType +from app.template_utils import is_missing_value, render_template_text, value_to_text + +EXTRACT_SYSTEM_PROMPT = """ +You are an information extraction engine. +Return strict JSON only. +Rules: +- Use only user input_text and optional profile_context. +- Never fabricate facts. +- If unknown, leave empty and mark as missing. +- list variables must always be arrays. +""".strip() + +RENDER_SYSTEM_PROMPT = """ +You are a constrained template renderer. +Rules: +- Preserve template structure and order. +- Replace placeholders only. +- No new factual claims unless provided in values. +""".strip() + +BULLET_PATTERN = re.compile(r"^\s*[-*]\s+(.+)$", re.MULTILINE) + + +def _variable_aliases(variable: str) -> list[str]: + normalized = variable.replace("_", " ").strip() + aliases = [ + variable, + normalized, + normalized.lower(), + normalized.title(), + normalized.replace(" ", "_"), + ] + seen: set[str] = set() + unique_aliases: list[str] = [] + for alias in aliases: + if alias and alias not in seen: + seen.add(alias) + unique_aliases.append(alias) + return unique_aliases + + +def _split_list_values(raw: str) -> list[str]: + if not raw.strip(): + return [] + bullet_items = [match.group(1).strip() for match in BULLET_PATTERN.finditer(raw)] + if bullet_items: + return [item for item in bullet_items if item] + + chunks = re.split(r"[\n,;|]", raw) + values = [chunk.strip(" -\t") for chunk in chunks if chunk.strip(" -\t")] + return values + + +def _extract_labeled_value(input_text: str, aliases: list[str]) -> tuple[str | None, str | None]: + for alias in aliases: + pattern = re.compile(rf"(?im)^\s*{re.escape(alias)}\s*[::\-]\s*(.+)$") + match = pattern.search(input_text) + if match: + return match.group(1).strip(), f"Matched labeled field '{alias}'" + return None, None + + +def _extract_heuristic_value(variable: str, input_text: str) -> tuple[str | None, str | None]: + lowered = variable.lower() + + if "company" in lowered: + match = re.search( + r"(?i)\b(?:company|at)\s*[::\-]?\s*([A-Z][A-Za-z0-9& .\-]{1,80})", + input_text, + ) + if match: + return match.group(1).strip(), "Matched company heuristic" + + if "role" in lowered or "position" in lowered or "job" in lowered: + match = re.search( + r"(?i)\b(?:role|position|job\s*title|title)\s*[::\-]?\s*([A-Za-z0-9& ./\-]{2,80})", + input_text, + ) + if match: + return match.group(1).strip(), "Matched role heuristic" + + return None, None + + +def _value_from_profile_context( + variable: str, profile_context: dict[str, Any] +) -> tuple[Any | None, str | None]: + aliases = _variable_aliases(variable) + for alias in aliases: + if alias in profile_context: + return profile_context[alias], f"Pulled from profile_context['{alias}']" + return None, None + + +def _coerce_value_for_type(value: Any, variable_type: TemplateVariableType) -> Any: + if variable_type == TemplateVariableType.list: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + if value is None: + return [] + return _split_list_values(str(value)) + + if value is None: + return "" + return value_to_text(value) + + +def extract_variables( + *, + input_text: str, + variables_schema: dict[str, Any], + profile_context: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], list[str], dict[str, float], dict[str, str]]: + profile = profile_context or {} + values: dict[str, Any] = {} + missing_required: list[str] = [] + confidence: dict[str, float] = {} + notes: dict[str, str] = {} + + for variable, raw_config in variables_schema.items(): + config = TemplateVariableConfig.model_validate(raw_config) + + profile_value, profile_note = _value_from_profile_context(variable, profile) + if profile_value is not None: + coerced = _coerce_value_for_type(profile_value, config.type) + values[variable] = coerced + confidence[variable] = 0.85 + if profile_note: + notes[variable] = profile_note + if config.required and is_missing_value(coerced, config.type): + missing_required.append(variable) + continue + + aliases = _variable_aliases(variable) + labeled_value, label_note = _extract_labeled_value(input_text, aliases) + + if labeled_value is not None: + coerced = _coerce_value_for_type(labeled_value, config.type) + values[variable] = coerced + confidence[variable] = 0.93 + if label_note: + notes[variable] = label_note + else: + heuristic_value, heuristic_note = _extract_heuristic_value(variable, input_text) + if heuristic_value is not None: + coerced = _coerce_value_for_type(heuristic_value, config.type) + values[variable] = coerced + confidence[variable] = 0.6 + if heuristic_note: + notes[variable] = heuristic_note + else: + empty_value = [] if config.type == TemplateVariableType.list else "" + values[variable] = empty_value + + if config.required and is_missing_value(values[variable], config.type): + missing_required.append(variable) + + return values, missing_required, confidence, notes + + +def render_template( + *, content: str, values: dict[str, Any], style: dict[str, Any] | None = None +) -> str: + # MVP renderer intentionally stays deterministic and constrained. + _ = style + return render_template_text(content, values) diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py new file mode 100644 index 0000000000..6288d9aa87 --- /dev/null +++ b/backend/app/services/template_service.py @@ -0,0 +1,162 @@ +import uuid +from typing import Any + +from sqlmodel import Session + +from app.models import ( + Template, + TemplateCategory, + TemplateCreate, + TemplateLanguage, + TemplateUpdate, + TemplateVersion, + TemplateVersionCreate, + User, + get_datetime_utc, +) +from app.repositories import template_repository +from app.services.exceptions import ForbiddenError, NotFoundError +from app.template_utils import normalize_variables_schema + + +def list_templates_for_user( + *, + session: Session, + current_user: User, + category: TemplateCategory | None = None, + language: TemplateLanguage | None = None, + search: str | None = None, + skip: int = 0, + limit: int = 100, +) -> tuple[list[Template], int]: + return template_repository.list_templates( + session=session, + user_id=current_user.id, + is_superuser=current_user.is_superuser, + category=category, + language=language, + search=search, + skip=skip, + limit=limit, + ) + + +def get_template_for_user( + *, session: Session, current_user: User, template_id: uuid.UUID +) -> Template: + template = template_repository.get_template_by_id( + session=session, template_id=template_id + ) + if template is None: + raise NotFoundError("Template not found") + if not current_user.is_superuser and template.user_id != current_user.id: + raise ForbiddenError("Not enough permissions") + return template + + +def create_template_for_user( + *, session: Session, current_user: User, template_in: TemplateCreate +) -> Template: + template = Template.model_validate( + template_in, update={"user_id": current_user.id, "updated_at": get_datetime_utc()} + ) + return template_repository.create_template(session=session, template=template) + + +def update_template_for_user( + *, + session: Session, + current_user: User, + template_id: uuid.UUID, + template_in: TemplateUpdate, +) -> Template: + template = get_template_for_user( + session=session, current_user=current_user, template_id=template_id + ) + update_data = template_in.model_dump(exclude_unset=True) + template.sqlmodel_update(update_data) + template.updated_at = get_datetime_utc() + return template_repository.save_template(session=session, template=template) + + +def list_template_versions_for_user( + *, session: Session, current_user: User, template_id: uuid.UUID +) -> list[TemplateVersion]: + get_template_for_user(session=session, current_user=current_user, template_id=template_id) + return template_repository.list_template_versions( + session=session, template_id=template_id + ) + + +def get_template_version_for_user( + *, + session: Session, + current_user: User, + template_version_id: uuid.UUID, +) -> TemplateVersion: + template_version = template_repository.get_template_version_by_id( + session=session, template_version_id=template_version_id + ) + if template_version is None: + raise NotFoundError("Template version not found") + get_template_for_user( + session=session, + current_user=current_user, + template_id=template_version.template_id, + ) + return template_version + + +def create_template_version_for_user( + *, + session: Session, + current_user: User, + template_id: uuid.UUID, + version_in: TemplateVersionCreate, +) -> TemplateVersion: + template = get_template_for_user( + session=session, current_user=current_user, template_id=template_id + ) + + raw_schema: dict[str, Any] = { + key: value.model_dump() if hasattr(value, "model_dump") else value + for key, value in version_in.variables_schema.items() + } + + normalized_schema = normalize_variables_schema( + version_in.content, + raw_schema, + ) + + version_number = template_repository.get_next_version_number( + session=session, + template_id=template.id, + ) + + template.updated_at = get_datetime_utc() + session.add(template) + + template_version = TemplateVersion.model_validate( + version_in, + update={ + "template_id": template.id, + "version": version_number, + "created_by": current_user.id, + "variables_schema": normalized_schema, + }, + ) + return template_repository.create_template_version( + session=session, template_version=template_version + ) + + +def get_template_latest_version( + *, session: Session, template_id: uuid.UUID +) -> TemplateVersion | None: + return template_repository.get_latest_template_version( + session=session, template_id=template_id + ) + + +def count_template_versions(*, session: Session, template_id: uuid.UUID) -> int: + return template_repository.count_template_versions(session=session, template_id=template_id) diff --git a/backend/app/template_utils.py b/backend/app/template_utils.py new file mode 100644 index 0000000000..38148e2c4f --- /dev/null +++ b/backend/app/template_utils.py @@ -0,0 +1,81 @@ +import re +from typing import Any + +from app.models import TemplateVariableConfig, TemplateVariableType + +PLACEHOLDER_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") + + +def extract_template_variables(content: str) -> list[str]: + """Extract unique variables from template content while preserving order.""" + seen: set[str] = set() + variables: list[str] = [] + for match in PLACEHOLDER_PATTERN.finditer(content): + variable = match.group(1) + if variable not in seen: + seen.add(variable) + variables.append(variable) + return variables + + +def _normalize_variable_config(raw: Any) -> TemplateVariableConfig: + if isinstance(raw, TemplateVariableConfig): + config = raw + elif isinstance(raw, dict): + try: + config = TemplateVariableConfig.model_validate(raw) + except Exception: + config = TemplateVariableConfig() + else: + config = TemplateVariableConfig() + + if config.type == TemplateVariableType.list and config.default is None: + config.default = [] + if config.type == TemplateVariableType.text and config.default is None: + config.default = "" + return config + + +def normalize_variables_schema( + content: str, raw_schema: dict[str, Any] | None +) -> dict[str, dict[str, Any]]: + variables = extract_template_variables(content) + schema_source = raw_schema or {} + normalized: dict[str, dict[str, Any]] = {} + + for variable in variables: + normalized[variable] = _normalize_variable_config( + schema_source.get(variable) + ).model_dump() + + return normalized + + +def is_missing_value(value: Any, variable_type: TemplateVariableType) -> bool: + if variable_type == TemplateVariableType.list: + if not isinstance(value, list): + return True + return len([item for item in value if str(item).strip()]) == 0 + if value is None: + return True + return str(value).strip() == "" + + +def value_to_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, list): + cleaned = [str(item).strip() for item in value if str(item).strip()] + return ", ".join(cleaned) + return str(value).strip() + + +def render_template_text(content: str, values: dict[str, Any]) -> str: + def _replacement(match: re.Match[str]) -> str: + variable = match.group(1) + value = values.get(variable) + return value_to_text(value) + + rendered = PLACEHOLDER_PATTERN.sub(_replacement, content) + rendered = re.sub(r"\n{3,}", "\n\n", rendered) + return rendered.strip() diff --git a/frontend-dev.err b/frontend-dev.err new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend-dev.log b/frontend-dev.log new file mode 100644 index 0000000000..fa1fcc6663 --- /dev/null +++ b/frontend-dev.log @@ -0,0 +1,9 @@ + +> frontend@0.0.0 dev +> vite --host 127.0.0.1 --port 5173 + +23:15:23 [vite] (client) Re-optimizing dependencies because lockfile has changed + + VITE v7.3.1 ready in 1361 ms + + ➜ Local: http://127.0.0.1:5173/ diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index 8502bcb9a4..ddd3074bf5 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Briefcase, Home, Users } from "lucide-react" +import { Briefcase, FileText, History, Home, Sparkles, Users } from "lucide-react" import { SidebarAppearance } from "@/components/Common/Appearance" import { Logo } from "@/components/Common/Logo" @@ -14,6 +14,10 @@ import { User } from "./User" const baseItems: Item[] = [ { icon: Home, title: "Dashboard", path: "/" }, + { icon: FileText, title: "Templates", path: "/templates" }, + { icon: Briefcase, title: "Editor", path: "/template-editor" }, + { icon: Sparkles, title: "Generate", path: "/generate" }, + { icon: History, title: "History", path: "/history" }, { icon: Briefcase, title: "Items", path: "/items" }, ] diff --git a/frontend/src/lib/templateMvpApi.ts b/frontend/src/lib/templateMvpApi.ts new file mode 100644 index 0000000000..04be9cdeca --- /dev/null +++ b/frontend/src/lib/templateMvpApi.ts @@ -0,0 +1,293 @@ +export type TemplateCategory = "cover_letter" | "email" | "proposal" | "other" +export type TemplateLanguage = "fr" | "en" | "zh" | "other" +export type TemplateVariableType = "text" | "list" + +export interface TemplateVariableConfig { + required: boolean + type: TemplateVariableType + description: string + example: unknown + default: unknown +} + +export interface TemplateVersion { + id: string + template_id: string + version: number + content: string + variables_schema: Record + created_at: string | null + created_by: string +} + +export interface TemplateSummary { + id: string + user_id: string + name: string + category: TemplateCategory + language: TemplateLanguage + tags: string[] + is_archived: boolean + created_at: string | null + updated_at: string | null + versions_count: number + latest_version_number: number | null +} + +export interface Template { + id: string + user_id: string + name: string + category: TemplateCategory + language: TemplateLanguage + tags: string[] + is_archived: boolean + created_at: string | null + updated_at: string | null + versions_count: number + latest_version: TemplateVersion | null +} + +export interface TemplatesResponse { + data: TemplateSummary[] + count: number +} + +export interface TemplateVersionsResponse { + data: TemplateVersion[] + count: number +} + +export interface ExtractVariablesResponse { + values: Record + missing_required: string[] + confidence: Record + notes: Record +} + +export interface Generation { + id: string + user_id: string + template_id: string + template_version_id: string + title: string + input_text: string + extracted_values: Record + output_text: string + created_at: string | null + updated_at: string | null +} + +export interface GenerationsResponse { + data: Generation[] + count: number +} + +export interface CreateTemplatePayload { + name: string + category: TemplateCategory + language: TemplateLanguage + tags: string[] +} + +export interface UpdateTemplatePayload { + name?: string + category?: TemplateCategory + language?: TemplateLanguage + tags?: string[] + is_archived?: boolean +} + +export interface CreateTemplateVersionPayload { + content: string + variables_schema: Record +} + +export interface ExtractVariablesPayload { + template_version_id: string + input_text: string + profile_context?: Record +} + +export interface RenderTemplatePayload { + template_version_id: string + values: Record + style?: { + tone?: string + length?: string + } +} + +export interface CreateGenerationPayload { + template_id: string + template_version_id: string + title: string + input_text: string + extracted_values: Record + output_text: string +} + +export interface UpdateGenerationPayload { + title?: string + extracted_values?: Record + output_text?: string +} + +const API_BASE = `${import.meta.env.VITE_API_URL.replace(/\/$/, "")}/api/v1` + +async function apiRequest(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers) + const token = localStorage.getItem("access_token") + if (token) { + headers.set("Authorization", `Bearer ${token}`) + } + + const response = await fetch(`${API_BASE}${path}`, { + ...init, + headers, + }) + + if (!response.ok) { + let detail = "Request failed" + try { + const body = (await response.json()) as { detail?: unknown } + if (typeof body.detail === "string") { + detail = body.detail + } + } catch { + // Ignore JSON parse errors and keep fallback message. + } + throw new Error(detail) + } + + return (await response.json()) as T +} + +export async function listTemplates( + params: { + category?: TemplateCategory + language?: TemplateLanguage + search?: string + } = {}, +): Promise { + const query = new URLSearchParams() + if (params.category) { + query.set("category", params.category) + } + if (params.language) { + query.set("language", params.language) + } + if (params.search) { + query.set("search", params.search) + } + + const suffix = query.toString() ? `/?${query.toString()}` : "/" + return apiRequest(`/templates${suffix}`) +} + +export async function createTemplate( + payload: CreateTemplatePayload, +): Promise