diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd37d82 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Version control and local editor state +.git +.github +.vscode + +# Local environment files (examples remain available to the build context) +.env +.env.* +!.env.example +!**/.env.example + +# JavaScript dependencies and build output +**/node_modules +**/dist +.pnpm-store +test-results +playwright-report +blob-report +**/playwright/.cache + +# Python environments and caches +.venv +**/__pycache__ +**/*.pyc +**/.mypy_cache +**/.ruff_cache +**/.pytest_cache +**/.coverage +**/htmlcov +**/*.egg-info + +# Local tooling and generated caches +.agents +.claude +.codex +.tanstack +.uv-cache diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..21b7aee --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# Copy this file to .env for local development. +# Replace every "changethis" value with a strong, unique secret. + +# Domain +DOMAIN=localhost + +# Frontend +FRONTEND_HOST=http://localhost:5173 + +# Environment: local, staging, production +ENVIRONMENT=local + +PROJECT_NAME="Bavly" +STACK_NAME=bavly + +# Backend +BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" +SECRET_KEY=changethis +FIRST_SUPERUSER=admin@example.com +FIRST_SUPERUSER_PASSWORD=changethis + +# Emails +SMTP_HOST= +SMTP_USER= +SMTP_PASSWORD= +EMAILS_FROM_EMAIL=info@example.com +SMTP_TLS=True +SMTP_SSL=False +SMTP_PORT=587 + +# Postgres +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=app +POSTGRES_USER=postgres +POSTGRES_PASSWORD=changethis + +SENTRY_DSN= + +# Local Docker images +DOCKER_IMAGE_BACKEND=backend +DOCKER_IMAGE_FRONTEND=frontend diff --git a/backend/app/alembic/versions/a7f3c9d2e841_replace_items_with_lessons.py b/backend/app/alembic/versions/a7f3c9d2e841_replace_items_with_lessons.py new file mode 100644 index 0000000..da2e751 --- /dev/null +++ b/backend/app/alembic/versions/a7f3c9d2e841_replace_items_with_lessons.py @@ -0,0 +1,45 @@ +"""Replace items with lessons + +Revision ID: a7f3c9d2e841 +Revises: fe56fa70289e +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "a7f3c9d2e841" +down_revision: str | None = "fe56fa70289e" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.rename_table("item", "lesson") + # Template items cannot be meaningfully mapped to lesson language settings. + op.execute("DELETE FROM lesson") + op.drop_column("lesson", "title") + op.drop_column("lesson", "description") + op.add_column( + "lesson", sa.Column("target_language", sa.String(length=255), nullable=False) + ) + op.add_column( + "lesson", sa.Column("native_language", sa.String(length=255), nullable=False) + ) + op.add_column("lesson", sa.Column("prompt", sa.String(length=4000), nullable=False)) + + +def downgrade() -> None: + op.drop_column("lesson", "prompt") + op.drop_column("lesson", "native_language") + op.drop_column("lesson", "target_language") + op.add_column( + "lesson", sa.Column("description", sa.String(length=255), nullable=True) + ) + op.add_column( + "lesson", + sa.Column("title", sa.String(length=255), nullable=False, server_default="Lesson"), + ) + op.alter_column("lesson", "title", server_default=None) + op.rename_table("lesson", "item") diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8..3cff23e 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,13 +1,13 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import lessons, login, private, users, utils from app.core.config import settings api_router = APIRouter() api_router.include_router(login.router) api_router.include_router(users.router) api_router.include_router(utils.router) -api_router.include_router(items.router) +api_router.include_router(lessons.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py deleted file mode 100644 index f0eb30e..0000000 --- a/backend/app/api/routes/items.py +++ /dev/null @@ -1,113 +0,0 @@ -import uuid -from typing import Any - -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 - -router = APIRouter(prefix="/items", tags=["items"]) - - -@router.get("/", response_model=ItemsPublic) -def read_items( - session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 -) -> Any: - """ - 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_public = [ItemPublic.model_validate(item) for item in items] - return ItemsPublic(data=items_public, count=count) - - -@router.get("/{id}", response_model=ItemPublic) -def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: - """ - 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 - - -@router.post("/", response_model=ItemPublic) -def create_item( - *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate -) -> Any: - """ - 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 - - -@router.put("/{id}", response_model=ItemPublic) -def update_item( - *, - session: SessionDep, - current_user: CurrentUser, - id: uuid.UUID, - item_in: ItemUpdate, -) -> Any: - """ - 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 - - -@router.delete("/{id}") -def delete_item( - session: SessionDep, current_user: CurrentUser, id: uuid.UUID -) -> Message: - """ - 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() - return Message(message="Item deleted successfully") diff --git a/backend/app/api/routes/lessons.py b/backend/app/api/routes/lessons.py new file mode 100644 index 0000000..995304b --- /dev/null +++ b/backend/app/api/routes/lessons.py @@ -0,0 +1,123 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import col, func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Lesson, + LessonCreate, + LessonPublic, + LessonsPublic, + LessonUpdate, + Message, +) + +router = APIRouter(prefix="/lessons", tags=["lessons"]) + + +@router.get("/", response_model=LessonsPublic) +def read_lessons( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve lessons. + """ + + if current_user.is_superuser: + count_statement = select(func.count()).select_from(Lesson) + count = session.exec(count_statement).one() + statement = ( + select(Lesson) + .order_by(col(Lesson.created_at).desc()) + .offset(skip) + .limit(limit) + ) + lessons = session.exec(statement).all() + else: + count_statement = ( + select(func.count()) + .select_from(Lesson) + .where(Lesson.owner_id == current_user.id) + ) + count = session.exec(count_statement).one() + statement = ( + select(Lesson) + .where(Lesson.owner_id == current_user.id) + .order_by(col(Lesson.created_at).desc()) + .offset(skip) + .limit(limit) + ) + lessons = session.exec(statement).all() + + lessons_public = [LessonPublic.model_validate(lesson) for lesson in lessons] + return LessonsPublic(data=lessons_public, count=count) + + +@router.get("/{id}", response_model=LessonPublic) +def read_lesson(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """ + Get lesson by ID. + """ + lesson = session.get(Lesson, id) + if not lesson: + raise HTTPException(status_code=404, detail="Lesson not found") + if not current_user.is_superuser and (lesson.owner_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + return lesson + + +@router.post("/", response_model=LessonPublic) +def create_lesson( + *, session: SessionDep, current_user: CurrentUser, lesson_in: LessonCreate +) -> Any: + """ + Create new lesson. + """ + lesson = Lesson.model_validate(lesson_in, update={"owner_id": current_user.id}) + session.add(lesson) + session.commit() + session.refresh(lesson) + return lesson + + +@router.put("/{id}", response_model=LessonPublic) +def update_lesson( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + lesson_in: LessonUpdate, +) -> Any: + """ + Update a lesson. + """ + lesson = session.get(Lesson, id) + if not lesson: + raise HTTPException(status_code=404, detail="Lesson not found") + if not current_user.is_superuser and (lesson.owner_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + update_dict = lesson_in.model_dump(exclude_unset=True) + lesson.sqlmodel_update(update_dict) + session.add(lesson) + session.commit() + session.refresh(lesson) + return lesson + + +@router.delete("/{id}") +def delete_lesson( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Delete a lesson. + """ + lesson = session.get(Lesson, id) + if not lesson: + raise HTTPException(status_code=404, detail="Lesson not found") + if not current_user.is_superuser and (lesson.owner_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + session.delete(lesson) + session.commit() + return Message(message="Lesson deleted successfully") diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 1748f58..221a2e3 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -13,7 +13,7 @@ from app.core.config import settings from app.core.security import get_password_hash, verify_password from app.models import ( - Item, + Lesson, Message, UpdatePassword, User, @@ -225,7 +225,7 @@ def delete_user( raise HTTPException( status_code=403, detail="Super users are not allowed to delete themselves" ) - statement = delete(Item).where(col(Item.owner_id) == user_id) + statement = delete(Lesson).where(col(Lesson.owner_id) == user_id) session.exec(statement) session.delete(user) session.commit() diff --git a/backend/app/crud.py b/backend/app/crud.py index a8ceba6..5b55602 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -4,7 +4,7 @@ from sqlmodel import Session, select from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models import Lesson, LessonCreate, User, UserCreate, UserUpdate def create_user(*, session: Session, user_create: UserCreate) -> User: @@ -60,9 +60,11 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None: return db_user -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) +def create_lesson( + *, session: Session, lesson_in: LessonCreate, owner_id: uuid.UUID +) -> Lesson: + db_lesson = Lesson.model_validate(lesson_in, update={"owner_id": owner_id}) + session.add(db_lesson) session.commit() - session.refresh(db_item) - return db_item + session.refresh(db_lesson) + return db_lesson diff --git a/backend/app/models.py b/backend/app/models.py index dcedf9a..8d33914 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,5 +1,6 @@ import uuid from datetime import UTC, datetime +from enum import StrEnum from pydantic import EmailStr from sqlalchemy import DateTime @@ -56,7 +57,7 @@ class User(UserBase, table=True): default_factory=get_datetime_utc, sa_type=DateTime(timezone=True), # type: ignore ) - items: list[Item] = Relationship(back_populates="owner", cascade_delete=True) + lessons: list[Lesson] = Relationship(back_populates="owner", cascade_delete=True) # Properties to return via API, id is always required @@ -71,24 +72,36 @@ class UsersPublic(SQLModel): # Shared properties -class ItemBase(SQLModel): - title: str = Field(min_length=1, max_length=255) - description: str | None = Field(default=None, max_length=255) +class TargetLanguage(StrEnum): + SPANISH = "Spanish" + FRENCH = "French" -# Properties to receive on item creation -class ItemCreate(ItemBase): +class NativeLanguage(StrEnum): + ENGLISH = "English" + + +# Shared lesson properties +class LessonBase(SQLModel): + target_language: TargetLanguage + native_language: NativeLanguage + prompt: str = Field(min_length=1, max_length=4000) + + +# Properties to receive on lesson creation +class LessonCreate(LessonBase): pass -# Properties to receive on item update -class ItemUpdate(SQLModel): - title: str | None = Field(default=None, min_length=1, max_length=255) - description: str | None = Field(default=None, max_length=255) +# Properties to receive on lesson update +class LessonUpdate(SQLModel): + target_language: TargetLanguage | None = None + native_language: NativeLanguage | None = None + prompt: str | None = Field(default=None, min_length=1, max_length=4000) # Database model, database table inferred from class name -class Item(ItemBase, table=True): +class Lesson(LessonBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) created_at: datetime | None = Field( default_factory=get_datetime_utc, @@ -97,18 +110,18 @@ class Item(ItemBase, table=True): owner_id: uuid.UUID = Field( foreign_key="user.id", nullable=False, ondelete="CASCADE" ) - owner: User | None = Relationship(back_populates="items") + owner: User | None = Relationship(back_populates="lessons") # Properties to return via API, id is always required -class ItemPublic(ItemBase): +class LessonPublic(LessonBase): id: uuid.UUID owner_id: uuid.UUID created_at: datetime | None = None -class ItemsPublic(SQLModel): - data: list[ItemPublic] +class LessonsPublic(SQLModel): + data: list[LessonPublic] count: int diff --git a/backend/tests/api/routes/test_items.py b/backend/tests/api/routes/test_items.py deleted file mode 100644 index 3e82cd0..0000000 --- a/backend/tests/api/routes/test_items.py +++ /dev/null @@ -1,164 +0,0 @@ -import uuid - -from fastapi.testclient import TestClient -from sqlmodel import Session - -from app.core.config import settings -from tests.utils.item import create_random_item - - -def test_create_item( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - data = {"title": "Foo", "description": "Fighters"} - response = client.post( - f"{settings.API_V1_STR}/items/", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == data["title"] - assert content["description"] == data["description"] - assert "id" in content - assert "owner_id" in content - - -def test_read_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.get( - f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == item.title - assert content["description"] == item.description - assert content["id"] == str(item.id) - assert content["owner_id"] == str(item.owner_id) - - -def test_read_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - response = client.get( - f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Item not found" - - -def test_read_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.get( - f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, - ) - assert response.status_code == 403 - content = response.json() - assert content["detail"] == "Not enough permissions" - - -def test_read_items( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - create_random_item(db) - create_random_item(db) - response = client.get( - f"{settings.API_V1_STR}/items/", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert len(content["data"]) >= 2 - - -def test_update_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - data = {"title": "Updated title", "description": "Updated description"} - response = client.put( - f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == data["title"] - assert content["description"] == data["description"] - assert content["id"] == str(item.id) - assert content["owner_id"] == str(item.owner_id) - - -def test_update_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - data = {"title": "Updated title", "description": "Updated description"} - response = client.put( - f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Item not found" - - -def test_update_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - data = {"title": "Updated title", "description": "Updated description"} - response = client.put( - f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, - json=data, - ) - assert response.status_code == 403 - content = response.json() - assert content["detail"] == "Not enough permissions" - - -def test_delete_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.delete( - f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert content["message"] == "Item deleted successfully" - - -def test_delete_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - response = client.delete( - f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Item not found" - - -def test_delete_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.delete( - f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, - ) - assert response.status_code == 403 - content = response.json() - assert content["detail"] == "Not enough permissions" diff --git a/backend/tests/api/routes/test_lessons.py b/backend/tests/api/routes/test_lessons.py new file mode 100644 index 0000000..4f96b4f --- /dev/null +++ b/backend/tests/api/routes/test_lessons.py @@ -0,0 +1,44 @@ +from fastapi.testclient import TestClient + +from app.core.config import settings + + +def test_create_and_read_lesson( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + payload = { + "target_language": "Spanish", + "native_language": "English", + "prompt": "Practice ordering food", + } + response = client.post( + f"{settings.API_V1_STR}/lessons/", + headers=superuser_token_headers, + json=payload, + ) + assert response.status_code == 200 + lesson = response.json() + assert lesson["target_language"] == "Spanish" + assert lesson["native_language"] == "English" + assert lesson["prompt"] == "Practice ordering food" + + response = client.get( + f"{settings.API_V1_STR}/lessons/", headers=superuser_token_headers + ) + assert response.status_code == 200 + assert lesson["id"] in {entry["id"] for entry in response.json()["data"]} + + +def test_rejects_unsupported_language( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.post( + f"{settings.API_V1_STR}/lessons/", + headers=superuser_token_headers, + json={ + "target_language": "German", + "native_language": "English", + "prompt": "Practice introductions", + }, + ) + assert response.status_code == 422 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7cdabf3..b6d8ddf 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,7 +7,7 @@ from app.core.config import settings from app.core.db import engine, init_db from app.main import app -from app.models import Item, User +from app.models import Lesson, User from tests.utils.user import authentication_token_from_email from tests.utils.utils import get_superuser_token_headers @@ -17,7 +17,7 @@ def db() -> Generator[Session]: with Session(engine) as session: init_db(session) yield session - statement = delete(Item) + statement = delete(Lesson) session.execute(statement) statement = delete(User) session.execute(statement) diff --git a/backend/tests/utils/item.py b/backend/tests/utils/item.py deleted file mode 100644 index ee51b35..0000000 --- a/backend/tests/utils/item.py +++ /dev/null @@ -1,16 +0,0 @@ -from sqlmodel import Session - -from app import crud -from app.models import Item, ItemCreate -from tests.utils.user import create_random_user -from tests.utils.utils import random_lower_string - - -def create_random_item(db: Session) -> Item: - user = create_random_user(db) - owner_id = user.id - assert owner_id is not None - title = random_lower_string() - description = random_lower_string() - item_in = ItemCreate(title=title, description=description) - return crud.create_item(session=db, item_in=item_in, owner_id=owner_id) diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..b60981a --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +# Copy this file to .env for local development. +VITE_API_URL=http://localhost:8000 +MAILCATCHER_HOST=http://localhost:1080 diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index ea7b4f5..b76b784 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -71,51 +71,39 @@ export const HTTPValidationErrorSchema = { title: 'HTTPValidationError' } as const; -export const ItemCreateSchema = { +export const LessonCreateSchema = { properties: { - title: { + target_language: { + '$ref': '#/components/schemas/TargetLanguage' + }, + native_language: { + '$ref': '#/components/schemas/NativeLanguage' + }, + prompt: { type: 'string', - maxLength: 255, + maxLength: 4000, minLength: 1, - title: 'Title' - }, - description: { - anyOf: [ - { - type: 'string', - maxLength: 255 - }, - { - type: 'null' - } - ], - title: 'Description' + title: 'Prompt' } }, type: 'object', - required: ['title'], - title: 'ItemCreate' + required: ['target_language', 'native_language', 'prompt'], + title: 'LessonCreate' } as const; -export const ItemPublicSchema = { +export const LessonPublicSchema = { properties: { - title: { + target_language: { + '$ref': '#/components/schemas/TargetLanguage' + }, + native_language: { + '$ref': '#/components/schemas/NativeLanguage' + }, + prompt: { type: 'string', - maxLength: 255, + maxLength: 4000, minLength: 1, - title: 'Title' - }, - description: { - anyOf: [ - { - type: 'string', - maxLength: 255 - }, - { - type: 'null' - } - ], - title: 'Description' + title: 'Prompt' }, id: { type: 'string', @@ -141,47 +129,55 @@ export const ItemPublicSchema = { } }, type: 'object', - required: ['title', 'id', 'owner_id'], - title: 'ItemPublic' + required: ['target_language', 'native_language', 'prompt', 'id', 'owner_id'], + title: 'LessonPublic' } as const; -export const ItemUpdateSchema = { +export const LessonUpdateSchema = { properties: { - title: { + target_language: { anyOf: [ { - type: 'string', - maxLength: 255, - minLength: 1 + '$ref': '#/components/schemas/TargetLanguage' }, { type: 'null' } - ], - title: 'Title' + ] }, - description: { + native_language: { + anyOf: [ + { + '$ref': '#/components/schemas/NativeLanguage' + }, + { + type: 'null' + } + ] + }, + prompt: { anyOf: [ { type: 'string', - maxLength: 255 + maxLength: 4000, + minLength: 1 }, { type: 'null' } ], - title: 'Description' + title: 'Prompt' } }, type: 'object', - title: 'ItemUpdate' + title: 'LessonUpdate' } as const; -export const ItemsPublicSchema = { +export const LessonsPublicSchema = { properties: { data: { items: { - '$ref': '#/components/schemas/ItemPublic' + '$ref': '#/components/schemas/LessonPublic' }, type: 'array', title: 'Data' @@ -193,7 +189,7 @@ export const ItemsPublicSchema = { }, type: 'object', required: ['data', 'count'], - title: 'ItemsPublic' + title: 'LessonsPublic' } as const; export const MessageSchema = { @@ -208,6 +204,12 @@ export const MessageSchema = { title: 'Message' } as const; +export const NativeLanguageSchema = { + type: 'string', + enum: ['English'], + title: 'NativeLanguage' +} as const; + export const NewPasswordSchema = { properties: { token: { @@ -251,6 +253,12 @@ export const PrivateUserCreateSchema = { title: 'PrivateUserCreate' } as const; +export const TargetLanguageSchema = { + type: 'string', + enum: ['Spanish', 'French'], + title: 'TargetLanguage' +} as const; + export const TokenSchema = { properties: { access_token: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f..18394db 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,22 +3,22 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { LessonsReadLessonsData, LessonsReadLessonsResponse, LessonsCreateLessonData, LessonsCreateLessonResponse, LessonsReadLessonData, LessonsReadLessonResponse, LessonsUpdateLessonData, LessonsUpdateLessonResponse, LessonsDeleteLessonData, LessonsDeleteLessonResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; -export class ItemsService { +export class LessonsService { /** - * Read Items - * Retrieve items. + * Read Lessons + * Retrieve lessons. * @param data The data for the request. * @param data.skip * @param data.limit - * @returns ItemsPublic Successful Response + * @returns LessonsPublic Successful Response * @throws ApiError */ - public static readItems(data: ItemsReadItemsData = {}): CancelablePromise { + public static readLessons(data: LessonsReadLessonsData = {}): CancelablePromise { return __request(OpenAPI, { method: 'GET', - url: '/api/v1/items/', + url: '/api/v1/lessons/', query: { skip: data.skip, limit: data.limit @@ -30,17 +30,17 @@ export class ItemsService { } /** - * Create Item - * Create new item. + * Create Lesson + * Create new lesson. * @param data The data for the request. * @param data.requestBody - * @returns ItemPublic Successful Response + * @returns LessonPublic Successful Response * @throws ApiError */ - public static createItem(data: ItemsCreateItemData): CancelablePromise { + public static createLesson(data: LessonsCreateLessonData): CancelablePromise { return __request(OpenAPI, { method: 'POST', - url: '/api/v1/items/', + url: '/api/v1/lessons/', body: data.requestBody, mediaType: 'application/json', errors: { @@ -50,17 +50,17 @@ export class ItemsService { } /** - * Read Item - * Get item by ID. + * Read Lesson + * Get lesson by ID. * @param data The data for the request. * @param data.id - * @returns ItemPublic Successful Response + * @returns LessonPublic Successful Response * @throws ApiError */ - public static readItem(data: ItemsReadItemData): CancelablePromise { + public static readLesson(data: LessonsReadLessonData): CancelablePromise { return __request(OpenAPI, { method: 'GET', - url: '/api/v1/items/{id}', + url: '/api/v1/lessons/{id}', path: { id: data.id }, @@ -71,18 +71,18 @@ export class ItemsService { } /** - * Update Item - * Update an item. + * Update Lesson + * Update a lesson. * @param data The data for the request. * @param data.id * @param data.requestBody - * @returns ItemPublic Successful Response + * @returns LessonPublic Successful Response * @throws ApiError */ - public static updateItem(data: ItemsUpdateItemData): CancelablePromise { + public static updateLesson(data: LessonsUpdateLessonData): CancelablePromise { return __request(OpenAPI, { method: 'PUT', - url: '/api/v1/items/{id}', + url: '/api/v1/lessons/{id}', path: { id: data.id }, @@ -95,17 +95,17 @@ export class ItemsService { } /** - * Delete Item - * Delete an item. + * Delete Lesson + * Delete a lesson. * @param data The data for the request. * @param data.id * @returns Message Successful Response * @throws ApiError */ - public static deleteItem(data: ItemsDeleteItemData): CancelablePromise { + public static deleteLesson(data: LessonsDeleteLessonData): CancelablePromise { return __request(OpenAPI, { method: 'DELETE', - url: '/api/v1/items/{id}', + url: '/api/v1/lessons/{id}', path: { id: data.id }, diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 596b8fc..2331508 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -13,33 +13,38 @@ export type HTTPValidationError = { detail?: Array; }; -export type ItemCreate = { - title: string; - description?: (string | null); +export type LessonCreate = { + target_language: TargetLanguage; + native_language: NativeLanguage; + prompt: string; }; -export type ItemPublic = { - title: string; - description?: (string | null); +export type LessonPublic = { + target_language: TargetLanguage; + native_language: NativeLanguage; + prompt: string; id: string; owner_id: string; created_at?: (string | null); }; -export type ItemsPublic = { - data: Array; +export type LessonsPublic = { + data: Array; count: number; }; -export type ItemUpdate = { - title?: (string | null); - description?: (string | null); +export type LessonUpdate = { + target_language?: (TargetLanguage | null); + native_language?: (NativeLanguage | null); + prompt?: (string | null); }; export type Message = { message: string; }; +export type NativeLanguage = 'English'; + export type NewPassword = { token: string; new_password: string; @@ -52,6 +57,8 @@ export type PrivateUserCreate = { is_verified?: boolean; }; +export type TargetLanguage = 'Spanish' | 'French'; + export type Token = { access_token: string; token_type?: string; @@ -113,37 +120,37 @@ export type ValidationError = { }; }; -export type ItemsReadItemsData = { +export type LessonsReadLessonsData = { limit?: number; skip?: number; }; -export type ItemsReadItemsResponse = (ItemsPublic); +export type LessonsReadLessonsResponse = (LessonsPublic); -export type ItemsCreateItemData = { - requestBody: ItemCreate; +export type LessonsCreateLessonData = { + requestBody: LessonCreate; }; -export type ItemsCreateItemResponse = (ItemPublic); +export type LessonsCreateLessonResponse = (LessonPublic); -export type ItemsReadItemData = { +export type LessonsReadLessonData = { id: string; }; -export type ItemsReadItemResponse = (ItemPublic); +export type LessonsReadLessonResponse = (LessonPublic); -export type ItemsUpdateItemData = { +export type LessonsUpdateLessonData = { id: string; - requestBody: ItemUpdate; + requestBody: LessonUpdate; }; -export type ItemsUpdateItemResponse = (ItemPublic); +export type LessonsUpdateLessonResponse = (LessonPublic); -export type ItemsDeleteItemData = { +export type LessonsDeleteLessonData = { id: string; }; -export type ItemsDeleteItemResponse = (Message); +export type LessonsDeleteLessonResponse = (Message); export type LoginLoginAccessTokenData = { formData: Body_login_login_access_token; diff --git a/frontend/src/components/Admin/DeleteUser.tsx b/frontend/src/components/Admin/DeleteUser.tsx index 4ffd023..0494290 100644 --- a/frontend/src/components/Admin/DeleteUser.tsx +++ b/frontend/src/components/Admin/DeleteUser.tsx @@ -66,7 +66,7 @@ const DeleteUser = ({ id, onSuccess }: DeleteUserProps) => { Delete User - All items associated with this user will also be{" "} + All lessons associated with this user will also be{" "} permanently deleted. Are you sure? You will not be able to undo this action. diff --git a/frontend/src/components/Items/AddItem.tsx b/frontend/src/components/Items/AddItem.tsx deleted file mode 100644 index 7c7c10c..0000000 --- a/frontend/src/components/Items/AddItem.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { Plus } from "lucide-react" -import { useState } from "react" -import { useForm } from "react-hook-form" -import { z } from "zod" - -import { type ItemCreate, ItemsService } from "@/client" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { LoadingButton } from "@/components/ui/loading-button" -import useCustomToast from "@/hooks/useCustomToast" -import { handleError } from "@/utils" - -const formSchema = z.object({ - title: z.string().min(1, { message: "Title is required" }), - description: z.string().optional(), -}) - -type FormData = z.infer - -const AddItem = () => { - const [isOpen, setIsOpen] = useState(false) - const queryClient = useQueryClient() - const { showSuccessToast, showErrorToast } = useCustomToast() - - const form = useForm({ - resolver: zodResolver(formSchema), - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - title: "", - description: "", - }, - }) - - const mutation = useMutation({ - mutationFn: (data: ItemCreate) => - ItemsService.createItem({ requestBody: data }), - onSuccess: () => { - showSuccessToast("Item created successfully") - form.reset() - setIsOpen(false) - }, - onError: handleError.bind(showErrorToast), - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["items"] }) - }, - }) - - const onSubmit = (data: FormData) => { - mutation.mutate(data) - } - - return ( - - - - - - - Add Item - - Fill in the details to add a new item. - - -
- -
- ( - - - Title * - - - - - - - )} - /> - - ( - - Description - - - - - - )} - /> -
- - - - - - - Save - - -
- -
-
- ) -} - -export default AddItem diff --git a/frontend/src/components/Items/DeleteItem.tsx b/frontend/src/components/Items/DeleteItem.tsx deleted file mode 100644 index 9e61c34..0000000 --- a/frontend/src/components/Items/DeleteItem.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { Trash2 } from "lucide-react" -import { useState } from "react" -import { useForm } from "react-hook-form" - -import { ItemsService } from "@/client" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { DropdownMenuItem } from "@/components/ui/dropdown-menu" -import { LoadingButton } from "@/components/ui/loading-button" -import useCustomToast from "@/hooks/useCustomToast" -import { handleError } from "@/utils" - -interface DeleteItemProps { - id: string - onSuccess: () => void -} - -const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => { - const [isOpen, setIsOpen] = useState(false) - const queryClient = useQueryClient() - const { showSuccessToast, showErrorToast } = useCustomToast() - const { handleSubmit } = useForm() - - const deleteItem = async (id: string) => { - await ItemsService.deleteItem({ id: id }) - } - - const mutation = useMutation({ - mutationFn: deleteItem, - onSuccess: () => { - showSuccessToast("The item was deleted successfully") - setIsOpen(false) - onSuccess() - }, - onError: handleError.bind(showErrorToast), - onSettled: () => { - queryClient.invalidateQueries() - }, - }) - - const onSubmit = async () => { - mutation.mutate(id) - } - - return ( - - e.preventDefault()} - onClick={() => setIsOpen(true)} - > - - Delete Item - - -
- - Delete Item - - This item will be permanently deleted. Are you sure? You will not - be able to undo this action. - - - - - - - - - Delete - - -
-
-
- ) -} - -export default DeleteItem diff --git a/frontend/src/components/Items/EditItem.tsx b/frontend/src/components/Items/EditItem.tsx deleted file mode 100644 index 3d57f55..0000000 --- a/frontend/src/components/Items/EditItem.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { Pencil } from "lucide-react" -import { useState } from "react" -import { useForm } from "react-hook-form" -import { z } from "zod" - -import { type ItemPublic, ItemsService } from "@/client" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { DropdownMenuItem } from "@/components/ui/dropdown-menu" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { LoadingButton } from "@/components/ui/loading-button" -import useCustomToast from "@/hooks/useCustomToast" -import { handleError } from "@/utils" - -const formSchema = z.object({ - title: z.string().min(1, { message: "Title is required" }), - description: z.string().optional(), -}) - -type FormData = z.infer - -interface EditItemProps { - item: ItemPublic - onSuccess: () => void -} - -const EditItem = ({ item, onSuccess }: EditItemProps) => { - const [isOpen, setIsOpen] = useState(false) - const queryClient = useQueryClient() - const { showSuccessToast, showErrorToast } = useCustomToast() - - const form = useForm({ - resolver: zodResolver(formSchema), - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - title: item.title, - description: item.description ?? undefined, - }, - }) - - const mutation = useMutation({ - mutationFn: (data: FormData) => - ItemsService.updateItem({ id: item.id, requestBody: data }), - onSuccess: () => { - showSuccessToast("Item updated successfully") - setIsOpen(false) - onSuccess() - }, - onError: handleError.bind(showErrorToast), - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["items"] }) - }, - }) - - const onSubmit = (data: FormData) => { - mutation.mutate(data) - } - - return ( - - e.preventDefault()} - onClick={() => setIsOpen(true)} - > - - Edit Item - - -
- - - Edit Item - - Update the item details below. - - -
- ( - - - Title * - - - - - - - )} - /> - - ( - - Description - - - - - - )} - /> -
- - - - - - - Save - - -
- -
-
- ) -} - -export default EditItem diff --git a/frontend/src/components/Items/ItemActionsMenu.tsx b/frontend/src/components/Items/ItemActionsMenu.tsx deleted file mode 100644 index 1efe7bf..0000000 --- a/frontend/src/components/Items/ItemActionsMenu.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { EllipsisVertical } from "lucide-react" -import { useState } from "react" - -import type { ItemPublic } from "@/client" -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import DeleteItem from "../Items/DeleteItem" -import EditItem from "../Items/EditItem" - -interface ItemActionsMenuProps { - item: ItemPublic -} - -export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => { - const [open, setOpen] = useState(false) - - return ( - - - - - - setOpen(false)} /> - setOpen(false)} /> - - - ) -} diff --git a/frontend/src/components/Items/columns.tsx b/frontend/src/components/Items/columns.tsx deleted file mode 100644 index b41be2a..0000000 --- a/frontend/src/components/Items/columns.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { ColumnDef } from "@tanstack/react-table" -import { Check, Copy } from "lucide-react" - -import type { ItemPublic } from "@/client" -import { Button } from "@/components/ui/button" -import { useCopyToClipboard } from "@/hooks/useCopyToClipboard" -import { cn } from "@/lib/utils" -import { ItemActionsMenu } from "./ItemActionsMenu" - -function CopyId({ id }: { id: string }) { - const [copiedText, copy] = useCopyToClipboard() - const isCopied = copiedText === id - - return ( -
- {id} - -
- ) -} - -export const columns: ColumnDef[] = [ - { - accessorKey: "id", - header: "ID", - cell: ({ row }) => , - }, - { - accessorKey: "title", - header: "Title", - cell: ({ row }) => ( - {row.original.title} - ), - }, - { - accessorKey: "description", - header: "Description", - cell: ({ row }) => { - const description = row.original.description - return ( - - {description || "No description"} - - ) - }, - }, - { - id: "actions", - header: () => Actions, - cell: ({ row }) => ( -
- -
- ), - }, -] diff --git a/frontend/src/components/Lessons/AddLesson.tsx b/frontend/src/components/Lessons/AddLesson.tsx new file mode 100644 index 0000000..e54c26c --- /dev/null +++ b/frontend/src/components/Lessons/AddLesson.tsx @@ -0,0 +1,175 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { Plus } from "lucide-react" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { type LessonCreate, LessonsService } from "@/client" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { LoadingButton } from "@/components/ui/loading-button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +const formSchema = z.object({ + target_language: z.enum(["Spanish", "French"]), + native_language: z.literal("English"), + prompt: z + .string() + .trim() + .min(1, { message: "Tell us what you want to learn or practice" }) + .max(4000, { message: "Prompt must be 4,000 characters or fewer" }), +}) + +type FormData = z.infer + +const AddLesson = () => { + const [isOpen, setIsOpen] = useState(false) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + defaultValues: { + target_language: "Spanish", + native_language: "English", + prompt: "", + }, + }) + + const mutation = useMutation({ + mutationFn: (data: LessonCreate) => + LessonsService.createLesson({ requestBody: data }), + onSuccess: () => { + showSuccessToast("Lesson created successfully") + form.reset() + setIsOpen(false) + }, + onError: handleError.bind(showErrorToast), + onSettled: () => + queryClient.invalidateQueries({ queryKey: ["lessons"] }), + }) + + return ( + + + + + + + Create a lesson + + Choose your languages and describe what you want to practice. + + +
+ mutation.mutate(data))} + > +
+ ( + + Language to practice + + + + )} + /> + ( + + Native language + + + + )} + /> +
+ ( + + What do you want to learn or practice? + +