diff --git a/backend/.env.example b/backend/.env.example index 6a4bf87..1b3bbd9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,6 +19,8 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=changethis # AI +AI_MAX_USAGE_QUOTA=30 +AI_QUOTA_TIME_RANGE_DAYS=30 # time in days AI_MODEL="dummy_model" AI_API_KEY="dummy_api_key" diff --git a/backend/src/alembic/versions/d1ea38d75310_add_ai_usage_quota_tables.py b/backend/src/alembic/versions/d1ea38d75310_add_ai_usage_quota_tables.py new file mode 100644 index 0000000..c0bddee --- /dev/null +++ b/backend/src/alembic/versions/d1ea38d75310_add_ai_usage_quota_tables.py @@ -0,0 +1,31 @@ +"""Add AI Usage Quota tables + +Revision ID: d1ea38d75310 +Revises: cb16ae472c1e +Create Date: 2025-05-04 09:59:20.325131 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'd1ea38d75310' +down_revision = 'cb16ae472c1e' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'aiusagequota', + sa.Column('id', sa.UUID(), primary_key=True, nullable=False), + sa.Column('user_id', sa.UUID(), sa.ForeignKey('user.id', ondelete='CASCADE'), index=True, nullable=False), + sa.Column('usage_count', sa.Integer, default=0, nullable=False), + sa.Column('last_reset_time', sa.DateTime(timezone=True), nullable=False), + ) + + +def downgrade(): + op.drop_table('aiusagequota') diff --git a/backend/src/core/config.py b/backend/src/core/config.py index bd66a54..6062b3d 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -64,6 +64,8 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: AI_API_KEY: str | None = None AI_MODEL: str | None = None + AI_MAX_USAGE_QUOTA: int = 30 + AI_QUOTA_TIME_RANGE_DAYS: int = 1 COLLECTION_GENERATION_PROMPT: str | None = None CARD_GENERATION_PROMPT: str | None = None @@ -92,6 +94,13 @@ def _enforce_non_default_secrets(self) -> Self: "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD ) + if self.AI_MAX_USAGE_QUOTA is None or self.AI_MAX_USAGE_QUOTA <= 0: + raise ValueError("AI_MAX_USAGE_QUOTA must be set to a positive integer.") + if self.AI_QUOTA_TIME_RANGE_DAYS is None or self.AI_QUOTA_TIME_RANGE_DAYS <= 0: + raise ValueError( + "AI_QUOTA_TIME_RANGE_DAYS must be set to a positive integer." + ) + return self diff --git a/backend/src/flashcards/api.py b/backend/src/flashcards/api.py index f657d87..96b01fa 100644 --- a/backend/src/flashcards/api.py +++ b/backend/src/flashcards/api.py @@ -7,6 +7,7 @@ from src.ai_models.gemini import GeminiProviderDep from src.ai_models.gemini.exceptions import AIGenerationError from src.auth.services import CurrentUser, SessionDep +from src.users.services import check_and_increment_ai_usage_quota from . import services from .exceptions import EmptyCollectionError @@ -52,6 +53,12 @@ async def create_collection( if collection_in.prompt: try: + if not await asyncio.to_thread( + lambda: check_and_increment_ai_usage_quota(session, current_user) + ): + raise HTTPException( + status_code=429, detail="Quota for AI usage is reached." + ) flashcard_collection = await services.generate_ai_collection( provider, collection_in.prompt ) @@ -145,6 +152,12 @@ async def create_card( if not access_checked: raise HTTPException(status_code=404, detail="Collection not found") if card_in.prompt: + if not await asyncio.to_thread( + lambda: check_and_increment_ai_usage_quota(session, current_user) + ): + raise HTTPException( + status_code=429, detail="Quota for AI usage is reached." + ) card_base = await services.generate_ai_flashcard(card_in.prompt, provider) card_in.front = card_base.front card_in.back = card_base.back diff --git a/backend/src/users/api.py b/backend/src/users/api.py index 4bd7007..f4c6704 100644 --- a/backend/src/users/api.py +++ b/backend/src/users/api.py @@ -4,7 +4,7 @@ from src.auth.services import CurrentUser, SessionDep from src.core.config import settings -from src.users.schemas import UserCreate, UserPublic, UserRegister +from src.users.schemas import AIUsageQuota, UserCreate, UserPublic, UserRegister from . import services @@ -38,3 +38,8 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any: user_create = UserCreate.model_validate(user_in) user = services.create_user(session=session, user_create=user_create) return user + + +@router.get("/users/me/ai-usage-quota", response_model=AIUsageQuota) +def get_my_ai_usage_quota(current_user: CurrentUser): + return services.get_ai_usage_quota_for_user(current_user) diff --git a/backend/src/users/models.py b/backend/src/users/models.py index 528005a..655afa9 100644 --- a/backend/src/users/models.py +++ b/backend/src/users/models.py @@ -1,7 +1,8 @@ import uuid +from datetime import datetime, timezone from typing import TYPE_CHECKING -from sqlmodel import Field, Relationship +from sqlmodel import Field, Relationship, SQLModel from src.users.schemas import UserBase @@ -22,3 +23,19 @@ class User(UserBase, table=True): cascade_delete=True, sa_relationship_kwargs={"lazy": "selectin"}, ) + ai_usage_quota: "AIUsageQuota" = Relationship( + back_populates="user", + sa_relationship_kwargs={"uselist": False, "lazy": "selectin"}, + ) + + +class AIUsageQuota(SQLModel, table=True): + id: uuid.UUID | None = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="user.id", index=True, unique=True, ondelete="CASCADE" + ) + usage_count: int = Field(default=0) + last_reset_time: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc) + ) + user: "User" = Relationship(back_populates="ai_usage_quota") diff --git a/backend/src/users/schemas.py b/backend/src/users/schemas.py index 5da1ce6..ebc490f 100644 --- a/backend/src/users/schemas.py +++ b/backend/src/users/schemas.py @@ -1,4 +1,5 @@ import uuid +from datetime import datetime from pydantic import EmailStr from sqlmodel import Field, SQLModel @@ -26,3 +27,9 @@ class UserRegister(SQLModel): class UserPublic(UserBase): id: uuid.UUID + + +class AIUsageQuota(SQLModel): + usage_count: int + max_usage_allowed: int + reset_date: datetime diff --git a/backend/src/users/services.py b/backend/src/users/services.py index 474948e..f6acb6a 100644 --- a/backend/src/users/services.py +++ b/backend/src/users/services.py @@ -1,11 +1,15 @@ import uuid +from datetime import datetime, timedelta, timezone from typing import Any -from sqlmodel import Session, select +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select, update from src.auth.services import get_password_hash +from src.core.config import settings +from src.users.models import AIUsageQuota as AIUsageQuotaModel from src.users.models import User -from src.users.schemas import UserCreate, UserUpdate +from src.users.schemas import AIUsageQuota, UserCreate, UserUpdate def create_user(*, session: Session, user_create: UserCreate) -> User: @@ -42,3 +46,67 @@ 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 + + +def get_ai_usage_quota_for_user(user: User) -> AIUsageQuota: + quota = user.ai_usage_quota + if not quota: + return AIUsageQuota( + usage_count=0, + max_usage_allowed=settings.AI_MAX_USAGE_QUOTA, + reset_date=( + datetime.now(timezone.utc) + + timedelta(days=settings.AI_QUOTA_TIME_RANGE_DAYS) + ), + ) + return AIUsageQuota( + usage_count=quota.usage_count, + max_usage_allowed=settings.AI_MAX_USAGE_QUOTA, + reset_date=( + quota.last_reset_time + timedelta(days=settings.AI_QUOTA_TIME_RANGE_DAYS) + ), + ) + + +def check_and_increment_ai_usage_quota(session: Session, user: User) -> bool: + now = datetime.now(timezone.utc) + reset_threshold = now - timedelta(days=settings.AI_QUOTA_TIME_RANGE_DAYS) + + if not user.ai_usage_quota: + try: + quota = AIUsageQuotaModel( + user_id=user.id, usage_count=1, last_reset_time=now + ) + session.add(quota) + session.commit() + return True + except IntegrityError: + session.rollback() + + session.refresh(user) + + result_reset = session.exec( + update(AIUsageQuotaModel) + .where( + (AIUsageQuotaModel.user_id == user.id) + & (AIUsageQuotaModel.last_reset_time <= reset_threshold) + ) + .values(usage_count=1, last_reset_time=now) + ) + + if result_reset.rowcount > 0: + session.commit() + return True + + result_increment = session.exec( + update(AIUsageQuotaModel) + .where( + (AIUsageQuotaModel.user_id == user.id) + & (AIUsageQuotaModel.last_reset_time > reset_threshold) + & (AIUsageQuotaModel.usage_count < settings.AI_MAX_USAGE_QUOTA) + ) + .values(usage_count=AIUsageQuotaModel.usage_count + 1) + ) + + session.commit() + return result_increment.rowcount > 0 diff --git a/backend/tests/flashcards/card/test_api.py b/backend/tests/flashcards/card/test_api.py index d4222bc..daec27e 100644 --- a/backend/tests/flashcards/card/test_api.py +++ b/backend/tests/flashcards/card/test_api.py @@ -394,18 +394,22 @@ def test_create_card_with_prompt_ai( with patch( "src.flashcards.services.generate_ai_flashcard", new_callable=AsyncMock ) as mock_ai: - mock_ai.return_value = type("Card", (), ai_card)() - card_data = {"prompt": prompt, "front": "", "back": ""} - rsp = client.post( - f"{settings.API_V1_STR}/collections/{collection_id}/cards/", - json=card_data, - headers=normal_user_token_headers, - ) - assert rsp.status_code == 200 - content = rsp.json() - assert content["front"] == ai_card["front"] - assert content["back"] == ai_card["back"] - mock_ai.assert_called_once_with(prompt, ANY) + with patch( + "src.flashcards.api.check_and_increment_ai_usage_quota" + ) as mock_quota_check: + mock_quota_check.return_value = True + mock_ai.return_value = type("Card", (), ai_card)() + card_data = {"prompt": prompt, "front": "", "back": ""} + rsp = client.post( + f"{settings.API_V1_STR}/collections/{collection_id}/cards/", + json=card_data, + headers=normal_user_token_headers, + ) + assert rsp.status_code == 200 + content = rsp.json() + assert content["front"] == ai_card["front"] + assert content["back"] == ai_card["back"] + mock_ai.assert_called_once_with(prompt, ANY) def test_create_card_with_prompt_too_long( diff --git a/backend/tests/flashcards/collection/test_api.py b/backend/tests/flashcards/collection/test_api.py index d2bdff0..63219b7 100644 --- a/backend/tests/flashcards/collection/test_api.py +++ b/backend/tests/flashcards/collection/test_api.py @@ -83,25 +83,29 @@ def test_create_collection_with_prompt( with patch( "src.flashcards.services.generate_ai_collection", new_callable=AsyncMock ) as mock_ai_generate: - mock_ai_generate.return_value = mock_collection - - rsp = client.post( - f"{settings.API_V1_STR}/collections/", - json=collection_data.model_dump(), - headers=normal_user_token_headers, - ) - - assert rsp.status_code == 200 - content = rsp.json() - assert content["name"] == collection_data.name - assert "id" in content - assert isinstance(content["id"], str) - assert len(content["cards"]) == len(mock_collection.cards) - for i, card in enumerate(mock_collection.cards): - assert content["cards"][i]["front"] == card.front - assert content["cards"][i]["back"] == card.back - - mock_ai_generate.assert_called_once() + with patch( + "src.flashcards.api.check_and_increment_ai_usage_quota" + ) as mock_quota_check: + mock_ai_generate.return_value = mock_collection + mock_quota_check.return_value = True + + rsp = client.post( + f"{settings.API_V1_STR}/collections/", + json=collection_data.model_dump(), + headers=normal_user_token_headers, + ) + + assert rsp.status_code == 200 + content = rsp.json() + assert content["name"] == collection_data.name + assert "id" in content + assert isinstance(content["id"], str) + assert len(content["cards"]) == len(mock_collection.cards) + for i, card in enumerate(mock_collection.cards): + assert content["cards"][i]["front"] == card.front + assert content["cards"][i]["back"] == card.back + + mock_ai_generate.assert_called_once() def test_create_collection_with_ai_generation_error( @@ -114,19 +118,23 @@ def test_create_collection_with_ai_generation_error( with patch( "src.flashcards.services.generate_ai_collection", new_callable=AsyncMock ) as mock_ai_generate: - err_msg = "AI service is unavailable" - mock_ai_generate.side_effect = AIGenerationError(err_msg) - - rsp = client.post( - f"{settings.API_V1_STR}/collections/", - json=collection_data.model_dump(), - headers=normal_user_token_headers, - ) - - assert rsp.status_code == 500 - content = rsp.json() - assert "detail" in content - assert err_msg in content["detail"] + with patch( + "src.flashcards.api.check_and_increment_ai_usage_quota" + ) as mock_quota_check: + err_msg = "AI service is unavailable" + mock_ai_generate.side_effect = AIGenerationError(err_msg) + mock_quota_check.return_value = True + + rsp = client.post( + f"{settings.API_V1_STR}/collections/", + json=collection_data.model_dump(), + headers=normal_user_token_headers, + ) + + assert rsp.status_code == 500 + content = rsp.json() + assert "detail" in content + assert err_msg in content["detail"] def test_read_collection( diff --git a/backend/tests/users/conftest.py b/backend/tests/users/conftest.py new file mode 100644 index 0000000..511080b --- /dev/null +++ b/backend/tests/users/conftest.py @@ -0,0 +1,20 @@ +from typing import Any + +import pytest +from sqlmodel import Session + +from src.users.schemas import UserCreate +from src.users.services import create_user +from tests.utils.utils import random_email, random_lower_string + + +@pytest.fixture +def test_user(db: Session) -> dict[str, Any]: + email = random_email() + password = random_lower_string() + full_name = random_lower_string() + + user_in = UserCreate(email=email, password=password, full_name=full_name) + user = create_user(session=db, user_create=user_in) + + return user diff --git a/backend/tests/users/test_services.py b/backend/tests/users/test_services.py index 970b4b4..f3a17a1 100644 --- a/backend/tests/users/test_services.py +++ b/backend/tests/users/test_services.py @@ -1,10 +1,20 @@ +from datetime import datetime, timezone +from unittest.mock import patch + from fastapi.encoders import jsonable_encoder from sqlmodel import Session from src.auth.services import verify_password -from src.users.models import User +from src.core.config import settings +from src.users.models import AIUsageQuota, User from src.users.schemas import UserCreate, UserUpdate -from src.users.services import create_user, get_user_by_email, update_user +from src.users.services import ( + check_and_increment_ai_usage_quota, + create_user, + get_ai_usage_quota_for_user, + get_user_by_email, + update_user, +) from tests.utils.utils import random_email, random_lower_string @@ -83,3 +93,94 @@ def test_update_user(db: Session) -> None: assert user_2 assert user.email == user_2.email assert verify_password(new_password, user_2.hashed_password) + + +def test_get_ai_usage_quota_for_user_no_quota(test_user): + test_user.ai_usage_quota = None + with patch("src.users.services.datetime") as mock_datetime: + mock_datetime.now.return_value = datetime(2025, 1, 1, tzinfo=timezone.utc) + quota = get_ai_usage_quota_for_user(test_user) + assert quota.usage_count == 0 + assert quota.reset_date == datetime(2025, 1, 31, tzinfo=timezone.utc) + + +def test_get_ai_usage_quota_for_user_with_quota(test_user): + test_user.ai_usage_quota = AIUsageQuota( + usage_count=50, last_reset_time=datetime(2025, 1, 1, tzinfo=timezone.utc) + ) + with patch("src.users.services.settings.AI_MAX_USAGE_QUOTA", 100): + with patch("src.users.services.datetime") as mock_datetime: + mock_datetime.now.return_value = datetime(2025, 1, 1, tzinfo=timezone.utc) + quota = get_ai_usage_quota_for_user(test_user) + assert quota.usage_count == 50 + assert quota.reset_date == datetime(2025, 1, 31, tzinfo=timezone.utc) + + +def test_check_and_increment_ai_usage_quota_first_time(db, test_user): + within_quota = check_and_increment_ai_usage_quota(db, test_user) + assert within_quota is True + + +def test_check_and_increment_ai_usage_quota_max_count_reached(db, test_user): + from src.users.models import AIUsageQuota as AIUsageQuotaModel + + quota = AIUsageQuotaModel( + user_id=test_user.id, + usage_count=settings.AI_MAX_USAGE_QUOTA, + last_reset_time=datetime.now(timezone.utc), + ) + db.add(quota) + db.commit() + db.refresh(test_user) + + within_quota = check_and_increment_ai_usage_quota(db, test_user) + assert within_quota is False + + +def test_check_and_increment_ai_usage_quota_reset_count(db, test_user): + from src.users.models import AIUsageQuota as AIUsageQuotaModel + + old_reset_time = datetime(2025, 1, 1, tzinfo=timezone.utc) + quota = AIUsageQuotaModel( + user_id=test_user.id, + usage_count=10, + last_reset_time=old_reset_time, + ) + db.add(quota) + db.commit() + db.refresh(test_user) + + with patch("src.users.services.datetime") as mock_datetime: + mock_datetime.now.return_value = datetime(2025, 2, 1, tzinfo=timezone.utc) + within_quota = check_and_increment_ai_usage_quota(db, test_user) + assert within_quota is True + + db.refresh(quota) + assert quota.usage_count == 1 + assert quota.last_reset_time == datetime(2025, 2, 1, tzinfo=timezone.utc) + + +def test_check_and_increment_ai_usage_quota_near_limit(db, test_user): + """Test that quota checking works correctly near the limit.""" + from src.users.models import AIUsageQuota as AIUsageQuotaModel + + quota = AIUsageQuotaModel( + user_id=test_user.id, + usage_count=settings.AI_MAX_USAGE_QUOTA - 1, + last_reset_time=datetime.now(timezone.utc), + ) + db.add(quota) + db.commit() + db.refresh(test_user) + + within_quota = check_and_increment_ai_usage_quota(db, test_user) + assert within_quota is True + + db.refresh(quota) + assert quota.usage_count == settings.AI_MAX_USAGE_QUOTA + + within_quota = check_and_increment_ai_usage_quota(db, test_user) + assert within_quota is False + + db.refresh(quota) + assert quota.usage_count == settings.AI_MAX_USAGE_QUOTA diff --git a/frontend/openapi.json b/frontend/openapi.json index b5ed7e5..536d565 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -80,6 +80,22 @@ } } }, + "/api/v1/users/users/me/ai-usage-quota": { + "get": { + "tags": ["users"], + "summary": "Get My Ai Usage Quota", + "operationId": "users-get_my_ai_usage_quota", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/AIUsageQuota" } } + } + } + }, + "security": [{ "OAuth2PasswordBearer": [] }] + } + }, "/api/v1/collections/": { "get": { "tags": ["flashcards"], @@ -692,6 +708,16 @@ }, "components": { "schemas": { + "AIUsageQuota": { + "properties": { + "usage_count": { "type": "integer", "title": "Usage Count" }, + "max_usage_allowed": { "type": "integer", "title": "Max Usage Allowed" }, + "reset_date": { "type": "string", "format": "date-time", "title": "Reset Date" } + }, + "type": "object", + "required": ["usage_count", "max_usage_allowed", "reset_date"], + "title": "AIUsageQuota" + }, "Body_login-login_access_token": { "properties": { "grant_type": { diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 304e480..77bdd87 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -84,6 +84,8 @@ "general": { "actions": { "addCard": "Add Card", + "aiQuotaResetDate": "AI Usage quota reset date", + "aiUsageLeft": "AI usage left", "generateAiCard": "Add Card with AI", "addCollection": "Add Collection", "backToCollection": "Back to Collection", @@ -120,6 +122,7 @@ "emailIsRequired": "Email is required", "errorCreatingAccount": "Error creating account", "errorLoadingCard": "Error loading card", + "errorLoadingAIUsageQuota": "Error loading AI usage quota", "errorSavingCard": "Error saving card", "invalidCredentials": "Invalid credentials", "invalidName": "Invalid name", diff --git a/frontend/public/locales/es/translation.json b/frontend/public/locales/es/translation.json index 62f764e..7108d1b 100644 --- a/frontend/public/locales/es/translation.json +++ b/frontend/public/locales/es/translation.json @@ -84,6 +84,8 @@ "general": { "actions": { "addCard": "Agregar Tarjeta", + "aiQuotaResetDate": "Fecha de reinicio de cuota de IA", + "aiUsageLeft": "Uso de IA restante", "generateAiCard": "Agregar tarjeta con IA", "addCollection": "Agregar Colección", "backToCollection": "Volver a la Colección", @@ -120,6 +122,7 @@ "emailIsRequired": "Correo electrónico es requerido", "errorCreatingAccount": "Error al crear la cuenta", "errorLoadingCard": "Error al cargar la tarjeta", + "errorLoadingAIUsageQuota": "Error al cargar cuota de uso de IA", "errorSavingCard": "Error al guardar la tarjeta", "invalidCredentials": "Credenciales inválidas", "invalidName": "Nombre inválido", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 39a694c..5f54f1d 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -84,6 +84,8 @@ "general": { "actions": { "addCard": "Kaart Toevoegen", + "aiQuotaResetDate": "Datum van herstart van AI-quota", + "aiUsageLeft": "Overgebleven AI-gebruik", "generateAiCard": "Kaart toevoegen met AI", "addCollection": "Collectie Toevoegen", "backToCollection": "Terug naar Collectie", @@ -120,6 +122,7 @@ "emailIsRequired": "E-mail is vereist", "errorCreatingAccount": "Fout bij het aanmaken van account", "errorLoadingCard": "Fout bij het laden van kaart", + "errorLoadingAIUsageQuota": "Fout bij het laden van AI-gebruiksquota", "errorSavingCard": "Fout bij het opslaan van kaart", "invalidCredentials": "Ongeldige inloggegevens", "invalidName": "Ongeldige naam", diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 59e577b..ea6a713 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -38,6 +38,7 @@ import type { LoginLoginAccessTokenResponse, StatsGetCollectionStatisticsEndpointData, StatsGetCollectionStatisticsEndpointResponse, + UsersGetMyAiUsageQuotaResponse, UsersReadUserMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, @@ -502,4 +503,16 @@ export class UsersService { }, }) } + + /** + * Get My Ai Usage Quota + * @returns AIUsageQuota Successful Response + * @throws ApiError + */ + public static getMyAiUsageQuota(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/users/users/me/ai-usage-quota', + }) + } } diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 6f98fcb..d260da6 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1,5 +1,11 @@ // This file is auto-generated by @hey-api/openapi-ts +export type AIUsageQuota = { + usage_count: number + max_usage_allowed: number + reset_date: string +} + export type Body_login_login_access_token = { grant_type?: string | null username: string @@ -283,3 +289,5 @@ export type UsersRegisterUserData = { } export type UsersRegisterUserResponse = UserPublic + +export type UsersGetMyAiUsageQuotaResponse = AIUsageQuota diff --git a/frontend/src/components/commonUI/AiPromptDialog.tsx b/frontend/src/components/commonUI/AiPromptDialog.tsx index c92230a..f5575dc 100644 --- a/frontend/src/components/commonUI/AiPromptDialog.tsx +++ b/frontend/src/components/commonUI/AiPromptDialog.tsx @@ -1,3 +1,5 @@ +import { UsersService } from '@/client' +import type { AIUsageQuota } from '@/client/types.gen' import { DialogActionTrigger, DialogBody, @@ -9,6 +11,7 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Text } from '@chakra-ui/react' +import { useQuery } from '@tanstack/react-query' import type { OpenChangeDetails } from 'node_modules/@chakra-ui/react/dist/types/components/dialog/namespace' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -37,6 +40,16 @@ const AiPromptDialog: React.FC = ({ const { t } = useTranslation() const [prompt, setPrompt] = useState('') const closeButtonRef = useRef(null) + const { data } = useQuery({ + queryKey: ['usageQuota'], + queryFn: UsersService.getMyAiUsageQuota, + enabled: isOpen, + }) + const usageQuota: AIUsageQuota = data || { + max_usage_allowed: 0, + usage_count: 0, + reset_date: new Date().toLocaleDateString(), + } const handleSubmit = () => { if (!prompt.trim() || isLoading) return @@ -85,6 +98,12 @@ const AiPromptDialog: React.FC = ({ {prompt.length}/{MAX_CHARS} + + {`${t('general.actions.aiQuotaResetDate')}: ${new Date(usageQuota.reset_date).toLocaleDateString()}`} + + + {`${t('general.actions.aiUsageLeft')}: ${usageQuota.max_usage_allowed - usageQuota.usage_count}`} + @@ -93,7 +112,14 @@ const AiPromptDialog: React.FC = ({ - + {isLoading ? `${t('general.actions.creating')}...` : t('general.actions.create')} diff --git a/frontend/src/hooks/useCard.ts b/frontend/src/hooks/useCard.ts index c4d4ede..50ae0ff 100644 --- a/frontend/src/hooks/useCard.ts +++ b/frontend/src/hooks/useCard.ts @@ -42,7 +42,7 @@ export function useCard(collectionId: string, cardId?: string) { } } catch (error) { toaster.create({ - title: t('general.errors.errorloadingCard'), + title: t('general.errors.errorLoadingCard'), type: 'error', }) } finally {